Analista de CSV
Haz preguntas sobre un trimestre de órdenes de e-commerce — "which region had
Showcase — Analista de CSV
Haz preguntas sobre un trimestre de órdenes de e-commerce — "which region had
the highest average revenue?", "total office units sold in May?" — y el modelo
responde encadenando tool calls: list_columns para aprender el schema, y luego
filter_rows y aggregate tantas veces como la pregunta lo necesite. Nunca ve
la tabla cruda, que es exactamente el patrón de acceso que quieres contra una
tabla de warehouse real que no cabe en un prompt.
Córrelo
bash bootstrap-secrets.sh # reads ../../../../.env, writes secrets/
docker compose up --build # default: PROVIDER=openai
Abre http://localhost:3000.
Para correrlo contra Gemini:
PROVIDER=gemini docker compose up --build
Qué hay aquí
backend/ai_openai.py— la tabla de órdenes embebida, las tres tools, el dict de dispatch y el loop de agente acotadobackend/ai_gemini.py— las mismas tools y loop, en la forma de Geminibackend/main.py— loader de FastAPI idéntico; lee PROVIDER y despachafrontend/app/page.tsx— textarea + resultadodocker-compose.yml— dos servicios, secrets montados desde./secrets/
El loop está topado en 8 iteraciones — un loop de agente sin cota es una caída esperando a un modelo confundido.
Detenlo
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.
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
.gitignoreREADME.es.mdREADME.mdbackend/Dockerfilebackend/ai_gemini.pybackend/ai_openai.pybackend/main.pybackend/requirements.txtbootstrap-secrets.shdocker-compose.ymlfrontend/Dockerfilefrontend/app/layout.tsxfrontend/app/page.tsxfrontend/next.config.tsfrontend/package.jsonfrontend/tsconfig.json