Presupuestador de viajes
"4 days in Tokyo and 3 in Paris, in Mexican pesos" toma cinco tool calls para
Showcase — Presupuestador de viajes
"4 days in Tokyo and 3 in Paris, in Mexican pesos" toma cinco tool calls para
responder con honestidad: dos búsquedas de costo por ciudad, un tipo de cambio y
llamadas a la calculadora para los totales. El modelo planea esa cadena él mismo
— el loop solo sigue ejecutando hasta que deja de pedir. La calculadora es la
estrella callada: un evaluador con AST en whitelist (números y + - * / nada más,
sin eval) para que la aritmética de varios pasos — eso que los modelos fallan
con confiabilidad — siempre corra en Python.
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— tablas de costo + fx, elcalccon AST en whitelist, el dict de dispatch y el loop acotadobackend/ai_gemini.py— las mismas tres tools, 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/
Las tablas de costo y de tipo de cambio son estáticas a propósito — la lección es el loop, no un feed de datos en vivo. Pregunta por una ciudad que no esté en la tabla y el error regresa como data listando cuáles sí están; observa cómo el modelo se recupera y te lo dice.
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 trip-budgeter.zip
cd trip-budgeter
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 3 (OpenAI): budget a trip by chaining lookups and math.
Three tools, three kinds of work: look up a city's daily cost, look up an
exchange rate, and a calculator. The model plans the chain — costs, then
the rate, then the arithmetic — and the calc tool exists so the one thing
models reliably fumble, multi-step arithmetic, runs in Python. The
evaluator is AST-whitelisted: numbers and + - * / ( ) only, no eval().
"""
import ast
import json
import operator
from openai import OpenAI
_client = OpenAI()
_MODEL = "gpt-5.4-nano"
# Deliberately static tables — this is a tool-loop lesson, not a data feed.
_DAILY_COST_USD = {
"tokyo": 210.0, "paris": 235.0, "mexico city": 120.0, "bangkok": 85.0,
"new york": 310.0, "lisbon": 140.0, "buenos aires": 95.0,
}
_USD_TO = {"usd": 1.0, "mxn": 18.7, "eur": 0.92, "jpy": 155.3, "gbp": 0.79}
_ALLOWED_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _safe_eval(node):
if isinstance(node, ast.Expression):
return _safe_eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(node.op)](_safe_eval(node.operand))
raise ValueError("only numbers and + - * / are allowed")
def _get_city_cost(city: str) -> dict:
cost = _DAILY_COST_USD.get(city.lower().strip())
if cost is None:
return {"error": f"no data for {city!r}", "known_cities": sorted(_DAILY_COST_USD)}
return {"city": city, "daily_cost_usd": cost,
"includes": "mid-range lodging, food, local transport"}
def _get_exchange_rate(currency: str) -> dict:
rate = _USD_TO.get(currency.lower().strip())
if rate is None:
return {"error": f"no rate for {currency!r}", "known": sorted(_USD_TO)}
return {"currency": currency.upper(), "rate_from_usd": rate}
def _calc(expression: str) -> dict:
try:
value = _safe_eval(ast.parse(expression, mode="eval"))
return {"expression": expression, "result": round(value, 2)}
except (ValueError, SyntaxError, ZeroDivisionError) as exc:
return {"error": str(exc)}
_DISPATCH = {"get_city_cost": _get_city_cost,
"get_exchange_rate": _get_exchange_rate,
"calc": _calc}
_TOOLS = [
{
"type": "function",
"name": "get_city_cost",
"description": "Typical daily travel cost for a city, in USD.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
{
"type": "function",
"name": "get_exchange_rate",
"description": "Exchange rate from USD to a currency (usd, mxn, eur, jpy, gbp).",
"parameters": {
"type": "object",
"properties": {"currency": {"type": "string"}},
"required": ["currency"],
},
},
{
"type": "function",
"name": "calc",
"description": "Evaluate an arithmetic expression exactly. "
"Numbers and + - * / ( ) only. Use this for ALL math.",
"parameters": {
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"],
},
},
]
def run(trip: str) -> str:
input_list = [{"role": "user", "content": trip.strip()}]
response = None
for _ in range(10): # bounded — never ship an open loop
response = _client.responses.create(
model=_MODEL,
instructions="Budget trips using the tools. Look up each city's "
"cost and the target currency's rate, and do every "
"piece of arithmetic with calc — never in your head. "
"Show the breakdown in your final answer.",
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 3 (Gemini): budget a trip by chaining lookups and math."""
import ast
import operator
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"
_DAILY_COST_USD = {
"tokyo": 210.0, "paris": 235.0, "mexico city": 120.0, "bangkok": 85.0,
"new york": 310.0, "lisbon": 140.0, "buenos aires": 95.0,
}
_USD_TO = {"usd": 1.0, "mxn": 18.7, "eur": 0.92, "jpy": 155.3, "gbp": 0.79}
_ALLOWED_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _safe_eval(node):
if isinstance(node, ast.Expression):
return _safe_eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPS:
return _ALLOWED_OPS[type(node.op)](_safe_eval(node.operand))
raise ValueError("only numbers and + - * / are allowed")
def _get_city_cost(city: str) -> dict:
cost = _DAILY_COST_USD.get(city.lower().strip())
if cost is None:
return {"error": f"no data for {city!r}", "known_cities": sorted(_DAILY_COST_USD)}
return {"city": city, "daily_cost_usd": cost,
"includes": "mid-range lodging, food, local transport"}
def _get_exchange_rate(currency: str) -> dict:
rate = _USD_TO.get(currency.lower().strip())
if rate is None:
return {"error": f"no rate for {currency!r}", "known": sorted(_USD_TO)}
return {"currency": currency.upper(), "rate_from_usd": rate}
def _calc(expression: str) -> dict:
try:
value = _safe_eval(ast.parse(expression, mode="eval"))
return {"expression": expression, "result": round(value, 2)}
except (ValueError, SyntaxError, ZeroDivisionError) as exc:
return {"error": str(exc)}
_DISPATCH = {"get_city_cost": _get_city_cost,
"get_exchange_rate": _get_exchange_rate,
"calc": _calc}
_CONFIG = types.GenerateContentConfig(
system_instruction="Budget trips using the tools. Look up each city's "
"cost and the target currency's rate, and do every "
"piece of arithmetic with calc — never in your head. "
"Show the breakdown in your final answer.",
tools=[types.Tool(function_declarations=[
types.FunctionDeclaration(
name="get_city_cost",
description="Typical daily travel cost for a city, in USD.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={"city": types.Schema(type=types.Type.STRING)},
required=["city"],
),
),
types.FunctionDeclaration(
name="get_exchange_rate",
description="Exchange rate from USD to a currency (usd, mxn, eur, jpy, gbp).",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={"currency": types.Schema(type=types.Type.STRING)},
required=["currency"],
),
),
types.FunctionDeclaration(
name="calc",
description="Evaluate an arithmetic expression exactly. "
"Numbers and + - * / ( ) only. Use this for ALL math.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={"expression": types.Schema(type=types.Type.STRING)},
required=["expression"],
),
),
])],
)
def run(trip: str) -> str:
contents = [types.Content(role="user", parts=[types.Part(text=trip.strip())])]
response = None
for _ in range(10): # 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