Course EN
← back to chapter

Escalador de recetas

Pega una receta y di "make it for 12" o "cut it in half". El modelo parsea la

Showcase — Escalador de recetas

Pega una receta y di "make it for 12" o "cut it in half". El modelo parsea la lista de ingredientes en texto libre a un array tipado — el parámetro array-of-objects del schema de la tool es la lección aquí — y Python escala cada cantidad con exactitud, renderizando 0.75 de vuelta como 3/4. Los modelos que escalan recetas de cabeza pierden ingredientes y se equivocan con las fracciones; un function call estructurado no puede.

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.pyscale_recipe con el schema array-of-objects, cantidades legibles basadas en Fraction, y el round trip
  • backend/ai_gemini.py — misma tool vía objetos types.Schema anidados
  • 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 cantidades escaladas regresan a través de fractions.Fraction, así que 1/3 de taza duplicado es 2/3 de taza — no 0.6666666.

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 recipe-scaler.zip

unzip recipe-scaler.zip
cd recipe-scaler
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 3 (OpenAI): scale a recipe with exact arithmetic.

The interesting part of the tool schema here is the array parameter: the
model parses the whole ingredient list out of free text into a typed
array of objects, and Python multiplies every quantity exactly. LLMs
drop items and fumble fractions when they scale recipes in their head;
a structured call can't.
"""
import json
from fractions import Fraction

from openai import OpenAI

_client = OpenAI()

_MODEL = "gpt-5.4-nano"


def _pretty(qty: float) -> str:
    """Render 0.75 as 3/4 — kitchen-friendly amounts, not floats."""
    frac = Fraction(qty).limit_denominator(8)
    whole, rem = divmod(frac.numerator, frac.denominator)
    if rem == 0:
        return str(whole)
    if whole == 0:
        return f"{rem}/{frac.denominator}"
    return f"{whole} {rem}/{frac.denominator}"


def _scale_recipe(ingredients: list, factor: float) -> dict:
    scaled = [{
        "name": item["name"],
        "quantity": _pretty(float(item["quantity"]) * factor),
        "unit": item.get("unit", ""),
    } for item in ingredients]
    return {"factor": factor, "ingredients": scaled}


_TOOLS = [{
    "type": "function",
    "name": "scale_recipe",
    "description": "Scale every ingredient quantity by a factor, exactly. "
                   "Pass ALL ingredients found in the recipe text.",
    "parameters": {
        "type": "object",
        "properties": {
            "ingredients": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string"},
                        "quantity": {"type": "number"},
                        "unit": {"type": "string"},
                    },
                    "required": ["name", "quantity"],
                },
            },
            "factor": {"type": "number",
                       "description": "e.g. 3 to triple, 0.5 to halve."},
        },
        "required": ["ingredients", "factor"],
    },
}]


def run(recipe_request: str) -> str:
    response = _client.responses.create(
        model=_MODEL,
        instructions="Scale recipes. Parse the ingredients and the requested "
                     "factor from the text, call scale_recipe with all of them, "
                     "then present the scaled list. Never multiply quantities yourself.",
        input=recipe_request.strip(),
        tools=_TOOLS,
    )

    calls = [item for item in response.output if item.type == "function_call"]
    if not calls:
        return response.output_text

    outputs = []
    for call in calls:
        args = json.loads(call.arguments)
        try:
            result = _scale_recipe(**args)
        except (KeyError, TypeError, ValueError) 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 3 (Gemini): scale a recipe with exact arithmetic."""
import os
from fractions import Fraction

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"


def _pretty(qty: float) -> str:
    """Render 0.75 as 3/4 — kitchen-friendly amounts, not floats."""
    frac = Fraction(qty).limit_denominator(8)
    whole, rem = divmod(frac.numerator, frac.denominator)
    if rem == 0:
        return str(whole)
    if whole == 0:
        return f"{rem}/{frac.denominator}"
    return f"{whole} {rem}/{frac.denominator}"


def _scale_recipe(ingredients: list, factor: float) -> dict:
    scaled = [{
        "name": item["name"],
        "quantity": _pretty(float(item["quantity"]) * factor),
        "unit": item.get("unit", ""),
    } for item in ingredients]
    return {"factor": factor, "ingredients": scaled}


_CONFIG = types.GenerateContentConfig(
    system_instruction="Scale recipes. Parse the ingredients and the requested "
                       "factor from the text, call scale_recipe with all of them, "
                       "then present the scaled list. Never multiply quantities yourself.",
    tools=[types.Tool(function_declarations=[types.FunctionDeclaration(
        name="scale_recipe",
        description="Scale every ingredient quantity by a factor, exactly. "
                    "Pass ALL ingredients found in the recipe text.",
        parameters=types.Schema(
            type=types.Type.OBJECT,
            properties={
                "ingredients": types.Schema(
                    type=types.Type.ARRAY,
                    items=types.Schema(
                        type=types.Type.OBJECT,
                        properties={
                            "name": types.Schema(type=types.Type.STRING),
                            "quantity": types.Schema(type=types.Type.NUMBER),
                            "unit": types.Schema(type=types.Type.STRING),
                        },
                        required=["name", "quantity"],
                    ),
                ),
                "factor": types.Schema(type=types.Type.NUMBER,
                                       description="e.g. 3 to triple, 0.5 to halve."),
            },
            required=["ingredients", "factor"],
        ),
    )])],
)


def run(recipe_request: str) -> str:
    contents = [types.Content(role="user", parts=[types.Part(text=recipe_request.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 = _scale_recipe(**fc.args)
        except (KeyError, TypeError, ValueError) 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.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