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.py—scale_recipewith the array-of-objects schema, Fraction-based pretty quantities, and the round tripbackend/ai_gemini.py— same tool via nestedtypes.Schemaobjectsbackend/main.py— identical FastAPI loader; reads PROVIDER and dispatchesfrontend/app/page.tsx— textarea + resultdocker-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.
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
.gitignoreREADME.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