Project: Data Analyst Agent
Capstone two: an agent that ingests CSVs, answers in DuckDB SQL, draws charts, and CANNOT write to your database — GA-19's theory as a working build.
▶ Watch this reelWhat you'll learn
- Ingestion pipeline
- The SQL tool
- Charts & summaries
- Guardrails, evals & limits
Remember this
- Ingest CSV→Parquet with explicit reject reporting and per-session isolation; views give the model a typed, semantic-layered namespace
- The SQL tool carries GA-18's four fences and AG-02's actionable errors; routing sends analysis to SQL, visuals to the sandbox, semantics to retrieval
- Narrative cites every number via query references and abstains on whys; golden sets score honesty; efficiency gates keep questions at cents
Ingest
- CSV→Parquet, TRY_CAST + reject report with samples (coverage honesty).
- Per-session DuckDB connections (structural tenant isolation).
- Views = typed namespace + semantic layer (versioned config).
SQL tool
- GA-18 fences: parse → AST allow-list → LIMIT wrap → scoped identity.
- AG-02 actionable errors (unknown columns name available ones).
- list-tables exploration tool; GA-19 router in instructions.
Charts & narrative
- Sandbox code route (AG-03 contract) → linked artifacts.
- Invariant: every number cites its query · speculation labeled · whys abstained.
Guardrails & evals
- Upload caps, output screens, golden analyst set (correctness + honesty traps),
efficiency gates (cents/question), stated limits in product copy.
Build order: ingest → fenced tool → routing golden set → charts → honesty evals.
Code: run-sql: the whole tool
@mcp.tool()
def run_sql(query: str, session: str) -> SqlResult:
"""Execute read-only SQL over this session's tables (orders, …).
Use for aggregation, joins, trends. Do NOT use for file ops —
ATTACH/COPY/read_csv are rejected. Prefer explicit column lists.
Results capped at 500 rows."""
tree = sqlglot.parse_one(query)
if not isinstance(tree, sqlglot.exp.Select) or \
any(banned in tree.sql() for banned in
("ATTACH", "COPY", "read_csv", "write_")):
raise ToolError("only plain SELECTs over session tables allowed")
con = sessions.connect(session) # structural isolation
rows = con.execute(f"SELECT * FROM ({tree.sql()}) _q LIMIT 500")
return SqlResult(columns=rows.columns, rows=rows.fetchall(),
row_count=rows.rowcount, query=tree.sql())