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.
One question goes through six steps. Each step lives in its own file, so you can learn, test and change it separately.
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).
| Choice | Why | Industry practice it follows |
|---|---|---|
| Local model via Ollama | Free, works offline, and company data never leaves the laptop | Data residency and privacy by design. The same code can later point at a hosted model. |
| Generate SQL, not pandas code | Small 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 memory | No server, fast on CSVs, strong date functions | An embedded analytical database, as used for local analytics and data apps |
| Schema with types and allowed values | The model sees status (values: completed, pending) instead of guessing | Grounding the model with schema context |
knowledge.py business terms | Defines revenue, order value and joins once | A lightweight semantic layer (the idea behind dbt metrics and BI semantic models) |
| Two layers of guardrails | The code guard refuses writes and file access; DuckDB itself has file access turned off and its settings locked | OWASP Top 10 for LLM Apps 2025: LLM05 Improper Output Handling, LLM06 Excessive Agency, LLM10 Unbounded Consumption (row limit) |
| Self-correction loop | DuckDB's error message tells the model exactly which column was wrong | Standard agent pattern: act, observe the result, retry with a limit |
| Golden question set | Measures accuracy, so a model or prompt change is a decision based on numbers | Execution accuracy, the metric used by the Spider and BIRD text-to-SQL benchmarks |
| Tests with a fake model | Fast and repeatable, with no AI needed to test the plumbing | Dependency injection and unit testing |
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 original | Risk | Fix in v2 |
|---|---|---|
| AI-written SQL runs with full DuckDB power | A query could read any file on the laptop (read_csv('C:/...')), write files with COPY, or install extensions | sql_guard.py + locked connection in database.py |
| No row limit | A cross join can return millions of rows and freeze the app | At 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 other | One agent.py used by both interfaces |
| The terminal version reloads all 10 CSVs for every question | Slow | Load once into DuckDB |
| Schema shows column names only | The model guesses status values and types | Types, row counts and allowed text values |
"Revenue" is not defined, and employees is missing from the relationships | Wrong numbers for revenue questions; employee questions fail | knowledge.py glossary and relationships |
| Few-shot examples equal the sample questions | Testing on the examples only measures copying | Separate eval/golden.json with different questions |
| No tests, no accuracy measure | No way to prove a change helps | tests/ + evaluate.py |
| If Ollama is off, you get a long stack trace | Confusing for new users | Clear messages: "Open the Ollama app", "Run: ollama pull ..." |
streamlit missing from the install line; unused legacy files | Setup fails; confusing code | requirements.txt; legacy files removed |
| Item | Version / size | Cost |
|---|---|---|
| Python | 3.11 or 3.12 (the original ran on 3.12) | Free |
| Ollama | Latest, from ollama.com/download (Windows, macOS, Linux) | Free |
qwen2.5-coder:1.5b | 986 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 extension | Any recent version | Free |
| Starter project | ai-data-agent-v2 folder (with the same 10 CSV files) | Provided |
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.
# 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 -qThe model replies with a SQL statement, and pytest reports all tests passing:
22 passedThe 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.
"""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 - 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))You see each table with types, and the status values for filings and tickets. Filings: 1,015 completed, 985 pending.
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 985This 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.
# 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"""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"]from llm import ask_ollama
print(ask_ollama("SQL to count rows in table orders"))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.
"""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"),
]"""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: 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.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).
"""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"""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)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 worksNo 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.
"""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"""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()"""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.schemapython 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 seesAttempt 1 (ok)
SELECT COUNT(*) AS pending_filings FROM filings WHERE status = 'pending'
pending_filings
985
1 rows in 2.1sAnswers 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.
streamlit run app.py
# opens http://localhost:8501"""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)}")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.
[
{"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)"}
]"""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()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.pyPASS 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.csvFill in this table for your team and keep it with the POC.
| Model | Correct / 10 | Avg seconds | Questions 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.
Computed directly from the CSV files, independently of the agent. Use these to check your results.
| Question | Correct answer |
|---|---|
| How many filings are pending | 985 (completed: 1,015) |
| Number of open support tickets | 970 (closed: 1,030) |
| Total revenue | 2,539,983 |
| Total revenue by region | South 560,792 · North 535,390 · West 509,348 · East 485,725 · Central 448,728 |
| Total of all payments | 5,130,008 (2,000 payments) |
| Average payment amount | 2,565.00 |
| Orders per month | Mar 2026: 744 · Apr: 720 · May: 536 |
| Customers per region | Central 409 · East 379 · North 415 · South 401 · West 396 |
| Employees per region | Central 42 · East 35 · North 42 · South 44 · West 37 |
| Customers with more than 4 filings | 11 customers, each with 5 filings |
| Average order value | 1,993.71 (over 1,274 orders that have items) |
| Top 5 customers by total payments | 1103 (17,587) · 820 (17,079) · 237 (15,097) · 1790 (13,850) · 1093 (13,636) |
| Symptom | Cause | Fix |
|---|---|---|
| "Cannot reach Ollama at http://localhost:11434" | Ollama not running | Open the Ollama app, or run ollama serve in a separate terminal |
| "Model '...' is not installed" | Model not downloaded, or a typo | ollama list, then ollama pull qwen2.5-coder:1.5b |
| "The model took longer than 120s" | Laptop too slow for the model, or first load | Run it again (the first call loads the model), use the 1.5b model, close other apps |
| Binder Error: column "customer_name" not found | Model invented a column | Normally fixed by the retry. If it repeats, add a glossary line or an example |
| Correct-looking SQL, wrong numbers | Wrong business logic (e.g. revenue = price only) | Compare with the answer key; add or fix the glossary entry |
| Refused: Only SELECT queries are allowed | The model replied with text but no SQL, or a write statement | Rephrase as a question about data; check that the prompt still includes the rules |
activate blocked in PowerShell | Script execution policy | Set-ExecutionPolicy -Scope CurrentUser RemoteSigned |
pip install fails on duckdb | Very new or old Python | Use Python 3.11 or 3.12 |
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.
knowledge.py and at least 10 golden questions for your datapytest -q passesSwap the data/ folder and rewrite knowledge.py. This is the real test of the design.
A second model call turns the result table into two plain-English sentences for business users.
+ summary prompt in agent.pySave question, SQL, attempts, time and errors to a CSV. Failed questions become new golden questions.
+ append to logs/queries.csvReplace the keyword guard with an AST check using sqlglot (open source): only SELECT, only known tables.
pip install sqlglotPoint it at PostgreSQL with a read-only database user, so the database enforces safety too.
+ psycopg, read-only role"And only for South?" Pass the previous question and SQL into the next prompt.
+ history in prompts.pyWith 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).
A regional manager sees only their region: create filtered views per user and give the agent only those views.
+ per-user views in database.pyA 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.