Course ES
← back to chapter

Recipe scaler

Paste a recipe and say "make it for 12" or "cut it in half". The model parses

Showcase — Recipe scaler

Paste a recipe and say "make it for 12" or "cut it in half". The model parses the free-text ingredient list into a typed array — the tool schema's array-of- objects parameter is the lesson here — and Python scales every quantity exactly, rendering 0.75 back as 3/4. Models scaling recipes in their head drop ingredients and fumble fractions; a structured function call can't.

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.pyscale_recipe with the array-of-objects schema, Fraction-based pretty quantities, and the round trip
  • backend/ai_gemini.py — same tool via nested types.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/

The scaled quantities come back through fractions.Fraction, so 1/3 cup doubled is 2/3 cup — not 0.6666666.

Stop

docker compose down

Run locally

Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on localhost:3000.

Download 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

Type some input, pick a provider, and run the same code shown in Source against the live API. Sign-in required.


  

The same modules the Run button hits. The whole project (frontend, Dockerfile, compose) is in the ZIP under 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 ""

Project files

  • .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