Course EN
← 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

Ejecútalo en tu máquina

Descarga el proyecto como ZIP y córrelo con Docker. Levanta un backend FastAPI y un frontend Next.js en localhost:3000.

Descargar 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

Escribe algo, elige un proveedor y ejecuta el mismo código de Código contra la API real. Requiere iniciar sesión.


  

Los mismos módulos que ejecuta el botón Run. El proyecto completo (frontend, Dockerfile, compose) está en el ZIP, pestaña 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 ""

Archivos del proyecto

  • .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