Course EN
← back to chapter

Unit converter

Ask a conversion question in plain language — "how many cups is 1.5 liters?",

Showcase — Unit converter

Ask a conversion question in plain language — "how many cups is 1.5 liters?", "425°F in Celsius?" — and the model turns it into a convert function call. Exact Python arithmetic produces the number; the model only decides what to convert and writes the sentence around the result. Length, mass, volume, and temperature families, with the offset math for temperature handled in code.

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 conversion tables, the tool schema, and the two-call round trip (function_call out, function_call_output back)
  • backend/ai_gemini.py — same function, declared with typed Schema objects
  • 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/

run(input: str) -> str executes at most one tool round trip: if the model doesn't ask for a conversion, its plain answer comes straight back.

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 unit-converter.zip

unzip unit-converter.zip
cd unit-converter
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 5 - Showcase 1 (OpenAI): natural-language unit conversion.

The model reads the question and decides what to convert; plain Python
does the arithmetic. One tool, one round trip: function call out, exact
result back in, grounded answer out.
"""
import json

from openai import OpenAI

_client = OpenAI()

_MODEL = "gpt-5.4-nano"

# Exact conversion tables — the part the model is bad at and Python isn't.
_TO_BASE = {
    "length_m": {"mm": 0.001, "cm": 0.01, "meters": 1.0, "km": 1000.0,
                 "inches": 0.0254, "feet": 0.3048, "yards": 0.9144, "miles": 1609.344},
    "mass_kg": {"grams": 0.001, "kg": 1.0, "ounces": 0.028349523125, "pounds": 0.45359237},
    "volume_l": {"ml": 0.001, "liters": 1.0, "cups": 0.2365882365,
                 "pints": 0.473176473, "gallons": 3.785411784},
}

_UNITS = sorted({u for table in _TO_BASE.values() for u in table} | {"celsius", "fahrenheit", "kelvin"})


def _convert(value: float, from_unit: str, to_unit: str) -> float:
    # Temperature is offset-based, not factor-based — handle it apart.
    temps = {"celsius", "fahrenheit", "kelvin"}
    if from_unit in temps or to_unit in temps:
        c = {"celsius": lambda v: v,
             "fahrenheit": lambda v: (v - 32) * 5 / 9,
             "kelvin": lambda v: v - 273.15}[from_unit](value)
        out = {"celsius": c, "fahrenheit": c * 9 / 5 + 32, "kelvin": c + 273.15}[to_unit]
        return round(out, 4)
    for table in _TO_BASE.values():
        if from_unit in table and to_unit in table:
            return round(value * table[from_unit] / table[to_unit], 6)
    raise ValueError(f"cannot convert {from_unit} to {to_unit}")


_TOOLS = [{
    "type": "function",
    "name": "convert",
    "description": "Convert a value between measurement units exactly. "
                   "Units must belong to the same family (length, mass, volume, temperature).",
    "parameters": {
        "type": "object",
        "properties": {
            "value": {"type": "number"},
            "from_unit": {"type": "string", "enum": _UNITS},
            "to_unit": {"type": "string", "enum": _UNITS},
        },
        "required": ["value", "from_unit", "to_unit"],
    },
}]


def run(question: str) -> str:
    response = _client.responses.create(
        model=_MODEL,
        instructions="Answer unit-conversion questions. Always use the convert "
                     "tool for the math; never compute conversions yourself.",
        input=question.strip(),
        tools=_TOOLS,
    )

    calls = [item for item in response.output if item.type == "function_call"]
    if not calls:
        return response.output_text  # nothing to convert; the model said so

    outputs = []
    for call in calls:
        args = json.loads(call.arguments)
        try:
            result = {"result": _convert(**args)}
        except (ValueError, KeyError) as exc:
            result = {"error": str(exc)}
        outputs.append({
            "type": "function_call_output",
            "call_id": call.call_id,
            "output": json.dumps(result),
        })

    final = _client.responses.create(
        model=_MODEL,
        previous_response_id=response.id,
        input=outputs,
        tools=_TOOLS,
    )
    return final.output_text

backend/ai_gemini.py

"""Week 5 - Showcase 1 (Gemini): natural-language unit conversion."""
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"

_TO_BASE = {
    "length_m": {"mm": 0.001, "cm": 0.01, "meters": 1.0, "km": 1000.0,
                 "inches": 0.0254, "feet": 0.3048, "yards": 0.9144, "miles": 1609.344},
    "mass_kg": {"grams": 0.001, "kg": 1.0, "ounces": 0.028349523125, "pounds": 0.45359237},
    "volume_l": {"ml": 0.001, "liters": 1.0, "cups": 0.2365882365,
                 "pints": 0.473176473, "gallons": 3.785411784},
}

_UNITS = sorted({u for table in _TO_BASE.values() for u in table} | {"celsius", "fahrenheit", "kelvin"})


def _convert(value: float, from_unit: str, to_unit: str) -> float:
    temps = {"celsius", "fahrenheit", "kelvin"}
    if from_unit in temps or to_unit in temps:
        c = {"celsius": lambda v: v,
             "fahrenheit": lambda v: (v - 32) * 5 / 9,
             "kelvin": lambda v: v - 273.15}[from_unit](value)
        out = {"celsius": c, "fahrenheit": c * 9 / 5 + 32, "kelvin": c + 273.15}[to_unit]
        return round(out, 4)
    for table in _TO_BASE.values():
        if from_unit in table and to_unit in table:
            return round(value * table[from_unit] / table[to_unit], 6)
    raise ValueError(f"cannot convert {from_unit} to {to_unit}")


_CONFIG = types.GenerateContentConfig(
    system_instruction="Answer unit-conversion questions. Always use the convert "
                       "tool for the math; never compute conversions yourself.",
    tools=[types.Tool(function_declarations=[types.FunctionDeclaration(
        name="convert",
        description="Convert a value between measurement units exactly. "
                    "Units must belong to the same family (length, mass, volume, temperature).",
        parameters=types.Schema(
            type=types.Type.OBJECT,
            properties={
                "value": types.Schema(type=types.Type.NUMBER),
                "from_unit": types.Schema(type=types.Type.STRING, enum=_UNITS),
                "to_unit": types.Schema(type=types.Type.STRING, enum=_UNITS),
            },
            required=["value", "from_unit", "to_unit"],
        ),
    )])],
)


def run(question: str) -> str:
    contents = [types.Content(role="user", parts=[types.Part(text=question.strip())])]
    response = _client.models.generate_content(
        model=_MODEL, contents=contents, config=_CONFIG,
    )

    if not response.function_calls:
        return response.text or ""

    contents.append(response.candidates[0].content)
    parts = []
    for fc in response.function_calls:
        try:
            result = {"result": _convert(**fc.args)}
        except (ValueError, KeyError) as exc:
            result = {"error": str(exc)}
        parts.append(types.Part.from_function_response(name=fc.name, response=result))
    contents.append(types.Content(role="user", parts=parts))

    final = _client.models.generate_content(
        model=_MODEL, contents=contents, config=_CONFIG,
    )
    return final.text or ""

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