Course ES
← back to chapter

CSV analyst

Ask questions about a quarter of e-commerce orders — "which region had the

Showcase — CSV analyst

Ask questions about a quarter of e-commerce orders — "which region had the highest average revenue?", "total office units sold in May?" — and the model answers by chaining tool calls: list_columns to learn the schema, then filter_rows and aggregate as many times as the question needs. It never sees the raw table, which is exactly the access pattern you want against a real warehouse table that doesn't fit in a prompt.

Run

bash bootstrap-secrets.sh              # reads ../../../../.env, writes secrets/
docker compose up --build              # default: PROVIDER=openai

Open http://localhost:3000.

To run against Gemini instead:

PROVIDER=gemini docker compose up --build

What's where

  • backend/ai_openai.py — the embedded orders table, the three tools, the dispatch dict, and the bounded agent loop
  • backend/ai_gemini.py — same tools and loop, Gemini spelling
  • backend/main.py — identical FastAPI loader; reads PROVIDER and dispatches
  • frontend/app/page.tsx — textarea + result
  • docker-compose.yml — two services, secrets mounted from ./secrets/

The loop is capped at 8 iterations — an agent loop without a bound is an outage waiting for a confused model.

Stop

docker compose down

Run locally

Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on localhost:3000.

Download csv-analyst.zip

unzip csv-analyst.zip
cd csv-analyst
bash bootstrap-secrets.sh   # one-time: pulls API keys into ./secrets
docker compose up --build   # default provider: openai
# or:  PROVIDER=gemini docker compose up --build

Type some input, pick a provider, and run the same code shown in Source against the live API. Sign-in required.


  

The same modules the Run button hits. The whole project (frontend, Dockerfile, compose) is in the ZIP under README.

backend/ai_openai.py

"""Week 6 - Showcase 1 (OpenAI): question-answering over a table with chained tools.

The model never sees the whole dataset. It discovers the schema, filters,
and aggregates through three tools, chaining as many calls as the question
needs — the same access pattern you'd want against a real warehouse table
that doesn't fit in a prompt.
"""
import json

from openai import OpenAI

_client = OpenAI()

_MODEL = "gpt-5.4-nano"

# A quarter of e-commerce orders. Small enough to read, rich enough that
# real questions take several tool calls to answer.
_ROWS = [
    {"order_id": 1001, "region": "north", "category": "electronics", "units": 3, "unit_price": 199.0, "month": "april"},
    {"order_id": 1002, "region": "south", "category": "furniture", "units": 1, "unit_price": 649.0, "month": "april"},
    {"order_id": 1003, "region": "north", "category": "office", "units": 12, "unit_price": 8.5, "month": "april"},
    {"order_id": 1004, "region": "west", "category": "electronics", "units": 2, "unit_price": 349.0, "month": "april"},
    {"order_id": 1005, "region": "south", "category": "office", "units": 30, "unit_price": 4.2, "month": "may"},
    {"order_id": 1006, "region": "west", "category": "furniture", "units": 2, "unit_price": 289.0, "month": "may"},
    {"order_id": 1007, "region": "north", "category": "electronics", "units": 1, "unit_price": 999.0, "month": "may"},
    {"order_id": 1008, "region": "east", "category": "office", "units": 6, "unit_price": 12.0, "month": "may"},
    {"order_id": 1009, "region": "east", "category": "electronics", "units": 4, "unit_price": 149.0, "month": "june"},
    {"order_id": 1010, "region": "south", "category": "electronics", "units": 2, "unit_price": 449.0, "month": "june"},
    {"order_id": 1011, "region": "west", "category": "office", "units": 20, "unit_price": 6.8, "month": "june"},
    {"order_id": 1012, "region": "north", "category": "furniture", "units": 1, "unit_price": 1149.0, "month": "june"},
    {"order_id": 1013, "region": "east", "category": "furniture", "units": 3, "unit_price": 219.0, "month": "june"},
    {"order_id": 1014, "region": "south", "category": "office", "units": 15, "unit_price": 9.9, "month": "june"},
    {"order_id": 1015, "region": "west", "category": "electronics", "units": 5, "unit_price": 89.0, "month": "june"},
]

_NUMERIC = {"units", "unit_price", "revenue"}


def _with_revenue(row: dict) -> dict:
    return {**row, "revenue": round(row["units"] * row["unit_price"], 2)}


def _list_columns() -> dict:
    return {
        "columns": ["order_id", "region", "category", "units", "unit_price", "month", "revenue"],
        "note": "revenue is units * unit_price, precomputed per row",
        "row_count": len(_ROWS),
    }


def _filter_rows(column: str, op: str, value) -> dict:
    ops = {"eq": lambda a, b: a == b, "ne": lambda a, b: a != b,
           "gt": lambda a, b: a > b, "lt": lambda a, b: a < b}
    if op not in ops:
        return {"error": f"op must be one of {sorted(ops)}"}
    rows = [_with_revenue(r) for r in _ROWS]
    if column not in rows[0]:
        return {"error": f"no column {column!r}"}
    if column in _NUMERIC:
        try:
            value = float(value)
        except (TypeError, ValueError):
            return {"error": f"column {column!r} is numeric; got {value!r}"}
    matched = [r for r in rows if ops[op](r[column], value)]
    return {"count": len(matched), "rows": matched}


def _aggregate(column: str, op: str, group_by: str = "") -> dict:
    if column not in _NUMERIC:
        return {"error": f"aggregate column must be numeric: {sorted(_NUMERIC)}"}
    funcs = {"sum": sum, "min": min, "max": max, "count": len,
             "avg": lambda v: round(sum(v) / len(v), 2) if v else 0}
    if op not in funcs:
        return {"error": f"op must be one of {sorted(funcs)}"}
    rows = [_with_revenue(r) for r in _ROWS]
    if not group_by:
        return {op: funcs[op]([r[column] for r in rows])}
    if group_by not in rows[0]:
        return {"error": f"no column {group_by!r}"}
    groups: dict = {}
    for r in rows:
        groups.setdefault(r[group_by], []).append(r[column])
    return {str(k): funcs[op](v) for k, v in sorted(groups.items())}


_DISPATCH = {"list_columns": _list_columns, "filter_rows": _filter_rows, "aggregate": _aggregate}

_TOOLS = [
    {
        "type": "function",
        "name": "list_columns",
        "description": "The table's columns, types note, and row count. Call this first.",
        "parameters": {"type": "object", "properties": {}},
    },
    {
        "type": "function",
        "name": "filter_rows",
        "description": "Rows where <column> <op> <value>. Ops: eq, ne, gt, lt.",
        "parameters": {
            "type": "object",
            "properties": {
                "column": {"type": "string"},
                "op": {"type": "string", "enum": ["eq", "ne", "gt", "lt"]},
                "value": {"type": ["string", "number"]},
            },
            "required": ["column", "op", "value"],
        },
    },
    {
        "type": "function",
        "name": "aggregate",
        "description": "Aggregate a numeric column: sum, avg, min, max, count — "
                       "optionally grouped by another column.",
        "parameters": {
            "type": "object",
            "properties": {
                "column": {"type": "string"},
                "op": {"type": "string", "enum": ["sum", "avg", "min", "max", "count"]},
                "group_by": {"type": "string"},
            },
            "required": ["column", "op"],
        },
    },
]


def run(question: str) -> str:
    input_list = [{"role": "user", "content": question.strip()}]
    response = None
    for _ in range(8):  # bounded — never ship an open loop
        response = _client.responses.create(
            model=_MODEL,
            instructions="Answer questions about the orders table using the "
                         "tools. Start from list_columns if unsure of the schema. "
                         "Base every number on tool results.",
            input=input_list,
            tools=_TOOLS,
        )
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            break
        input_list += response.output
        for call in calls:
            args = json.loads(call.arguments)
            fn = _DISPATCH.get(call.name)
            result = fn(**args) if fn else {"error": f"unknown tool {call.name}"}
            input_list.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result),
            })
    return response.output_text if response else ""

backend/ai_gemini.py

"""Week 6 - Showcase 1 (Gemini): question-answering over a table with chained tools."""
import os

from google import genai
from google.genai import types

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

_MODEL = "gemini-3.1-flash-lite"

_ROWS = [
    {"order_id": 1001, "region": "north", "category": "electronics", "units": 3, "unit_price": 199.0, "month": "april"},
    {"order_id": 1002, "region": "south", "category": "furniture", "units": 1, "unit_price": 649.0, "month": "april"},
    {"order_id": 1003, "region": "north", "category": "office", "units": 12, "unit_price": 8.5, "month": "april"},
    {"order_id": 1004, "region": "west", "category": "electronics", "units": 2, "unit_price": 349.0, "month": "april"},
    {"order_id": 1005, "region": "south", "category": "office", "units": 30, "unit_price": 4.2, "month": "may"},
    {"order_id": 1006, "region": "west", "category": "furniture", "units": 2, "unit_price": 289.0, "month": "may"},
    {"order_id": 1007, "region": "north", "category": "electronics", "units": 1, "unit_price": 999.0, "month": "may"},
    {"order_id": 1008, "region": "east", "category": "office", "units": 6, "unit_price": 12.0, "month": "may"},
    {"order_id": 1009, "region": "east", "category": "electronics", "units": 4, "unit_price": 149.0, "month": "june"},
    {"order_id": 1010, "region": "south", "category": "electronics", "units": 2, "unit_price": 449.0, "month": "june"},
    {"order_id": 1011, "region": "west", "category": "office", "units": 20, "unit_price": 6.8, "month": "june"},
    {"order_id": 1012, "region": "north", "category": "furniture", "units": 1, "unit_price": 1149.0, "month": "june"},
    {"order_id": 1013, "region": "east", "category": "furniture", "units": 3, "unit_price": 219.0, "month": "june"},
    {"order_id": 1014, "region": "south", "category": "office", "units": 15, "unit_price": 9.9, "month": "june"},
    {"order_id": 1015, "region": "west", "category": "electronics", "units": 5, "unit_price": 89.0, "month": "june"},
]

_NUMERIC = {"units", "unit_price", "revenue"}


def _with_revenue(row: dict) -> dict:
    return {**row, "revenue": round(row["units"] * row["unit_price"], 2)}


def _list_columns() -> dict:
    return {
        "columns": ["order_id", "region", "category", "units", "unit_price", "month", "revenue"],
        "note": "revenue is units * unit_price, precomputed per row",
        "row_count": len(_ROWS),
    }


def _filter_rows(column: str, op: str, value) -> dict:
    ops = {"eq": lambda a, b: a == b, "ne": lambda a, b: a != b,
           "gt": lambda a, b: a > b, "lt": lambda a, b: a < b}
    if op not in ops:
        return {"error": f"op must be one of {sorted(ops)}"}
    rows = [_with_revenue(r) for r in _ROWS]
    if column not in rows[0]:
        return {"error": f"no column {column!r}"}
    if column in _NUMERIC:
        try:
            value = float(value)
        except (TypeError, ValueError):
            return {"error": f"column {column!r} is numeric; got {value!r}"}
    matched = [r for r in rows if ops[op](r[column], value)]
    return {"count": len(matched), "rows": matched}


def _aggregate(column: str, op: str, group_by: str = "") -> dict:
    if column not in _NUMERIC:
        return {"error": f"aggregate column must be numeric: {sorted(_NUMERIC)}"}
    funcs = {"sum": sum, "min": min, "max": max, "count": len,
             "avg": lambda v: round(sum(v) / len(v), 2) if v else 0}
    if op not in funcs:
        return {"error": f"op must be one of {sorted(funcs)}"}
    rows = [_with_revenue(r) for r in _ROWS]
    if not group_by:
        return {op: funcs[op]([r[column] for r in rows])}
    if group_by not in rows[0]:
        return {"error": f"no column {group_by!r}"}
    groups: dict = {}
    for r in rows:
        groups.setdefault(r[group_by], []).append(r[column])
    return {str(k): funcs[op](v) for k, v in sorted(groups.items())}


_DISPATCH = {"list_columns": _list_columns, "filter_rows": _filter_rows, "aggregate": _aggregate}

_CONFIG = types.GenerateContentConfig(
    system_instruction="Answer questions about the orders table using the tools. "
                       "Start from list_columns if unsure of the schema. "
                       "Base every number on tool results.",
    tools=[types.Tool(function_declarations=[
        types.FunctionDeclaration(
            name="list_columns",
            description="The table's columns, types note, and row count. Call this first.",
            parameters=types.Schema(type=types.Type.OBJECT, properties={}),
        ),
        types.FunctionDeclaration(
            name="filter_rows",
            description="Rows where <column> <op> <value>. Ops: eq, ne, gt, lt.",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={
                    "column": types.Schema(type=types.Type.STRING),
                    "op": types.Schema(type=types.Type.STRING, enum=["eq", "ne", "gt", "lt"]),
                    "value": types.Schema(type=types.Type.STRING,
                                          description="Comparison value; numbers as plain digits."),
                },
                required=["column", "op", "value"],
            ),
        ),
        types.FunctionDeclaration(
            name="aggregate",
            description="Aggregate a numeric column: sum, avg, min, max, count — "
                        "optionally grouped by another column.",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={
                    "column": types.Schema(type=types.Type.STRING),
                    "op": types.Schema(type=types.Type.STRING,
                                       enum=["sum", "avg", "min", "max", "count"]),
                    "group_by": types.Schema(type=types.Type.STRING),
                },
                required=["column", "op"],
            ),
        ),
    ])],
)


def run(question: str) -> str:
    contents = [types.Content(role="user", parts=[types.Part(text=question.strip())])]
    response = None
    for _ in range(8):  # bounded — never ship an open loop
        response = _client.models.generate_content(
            model=_MODEL, contents=contents, config=_CONFIG,
        )
        if not response.function_calls:
            break
        contents.append(response.candidates[0].content)
        parts = []
        for fc in response.function_calls:
            fn = _DISPATCH.get(fc.name)
            result = fn(**fc.args) if fn else {"error": f"unknown tool {fc.name}"}
            parts.append(types.Part.from_function_response(name=fc.name, response=result))
        contents.append(types.Content(role="user", parts=parts))
    return (response.text or "") if response else ""

Project files

  • .gitignore
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json