Course EN
← back to chapter

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, el calc con AST en whitelist, el dict de dispatch y el loop acotado
  • backend/ai_gemini.py — las mismas tres tools, en la forma de Gemini
  • backend/main.py — loader de FastAPI idéntico; lee PROVIDER y despacha
  • frontend/app/page.tsx — textarea + resultado
  • docker-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.

Descargar trip-budgeter.zip

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

  • .gitignore
  • README.es.md
  • 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