This guide will show you how to create a CLI tool to generate recipes and save them in Notion. The user will enter a list of ingredients via the CLI, and the application will pass them to the LLM to generate a full recipe and save it in Notion with a title, cover image, description, list of ingredients, and method.

We will build the app using the following:

  • Tune Studio with the Mixtral model
  • Notion to save the recipes
  • Serper image search API to generate cover images
  • Fire to generate the CLI

You can find the complete application here.

Demonstration

Here is an example of a user asking for a recipe using ingredients they have available by running the following command:

python main.py --topic='give me a recipe using bread, chicken, onion' \
  --page_id=<your-notion-page-id> \
  --model=rohan/mixtral-8x7b-inst-v0-1-32k

The recipe will be created on Notion as follows:

Here is a video demonstration of the process:

https://github.com/AsavariD/cookbook/assets/69451908/366359fe-0a57-4041-a6a2-2d8311e08e4e

Getting started

You need to install some Python dependencies and manage them using a virtual environment.

In your project folder, run the following command to create a new virtual environment:

python -m venv venv

Activate the virtual environment with the following command:

source venv/bin/activate

In your project folder, create a requirements.txt file and paste the following dependencies into it:

requests
fire
python-dotenv

Install the dependencies by running the following command:

pip install -r requirements.txt

Adding environment variables

The application integrates with Tune Studio, Notion, and Serper API. We’ll use environment variables to manage the keys for these services.

In your project folder, create a new .env file and add the following to it:

NOTION_KEY=<your_notion_secret_key>
TUNEAI_API_KEY=<your_tuneai_key>
SERPER_KEY=<your_serper_api_key>

Find your Tune Studio API key by selecting View API keys from the profile dropdown at the top right of the Tune Studio dashboard or on your profile page.

If you don’t already have a Notion account, sign up here. You’ll need to create a Creator Profile and create a new internal integration. Find your Internal Integration Secret on the integration configuration page by clicking Configure integration settings on the integration creation success dialog. Create a new page in Notion and note down the page’s id. Connect the page to the integration you created by clicking on the three dot menu on the top-right. Click Connect to and search for your integration.

Sign up for a free Serper account here, and follow the instructions to complete your account registration. Click API key in the sidebar to find your API key.

Calling the LLM

We’ll start by writing a basic function to query the LLM and get a response from it. We can then pass this function to the various queries to generate all the parts of the recipe.

In your project folder, create a new main.py file and add the following code to it:

main.py
from dotenv import load_dotenv

load_dotenv()
import os
import requests
import fire

NOTION_KEY = os.getenv("NOTION_KEY")
TUNEAI_TOKEN = os.getenv("TUNEAI_API_KEY")
SERPER_API_KEY = os.getenv("SERPER_KEY")

Here we import the necessary modules and initialize the environment variables we configured earlier.

Let’s add some configurations for Notion and Tune Studio next:

main.py
notion_headers = {
    "Authorization": f"Bearer {NOTION_KEY}",
    "Content-Type": "application/json",
    "Notion-Version": "2022-06-28",
}

This configuration code sets the headers for queries to the Notion API.

Now add the following function to the main.py file:

main.py
def call_llm(messages, model = "MODEL_ID"):
    url = "https://proxy.tune.app/chat/completions"
    headers = {
        "Authorization": TUNEAI_TOKEN,
        "Content-Type": "application/json",
    }
    data = {
        "temperature": 0.8,
        "messages": messages,
        "model": model,
        "stream": False,
        "frequency_penalty": 0,
        "max_tokens": 500,
    }
    response = requests.post(url, headers=headers, json=data)
    response.raise_for_status()
    response_data = response.json()
    print(response_data)
    return response_data["choices"][0]["message"]["content"].strip()

This function receives a list of messages and queries the Tune Studio API for a response. We will use this function to generate the recipes by calling it with different prompts and supplying a model to use.

Defining helper functions

To generate the recipes, we need to query the model multiple times for different roles. We will write some helper functions to format the system and user messages for each query.

Add the following code to the main.py file:

main.py
def gen_recipe(dish_title, ingredients, num_ingredients, model):
    messages = [
        {
            "role": "system",
            "content": """You are a recipe generator. Your task is to generate the recipe using the title and ingredients. Follow these rules strictly:
            1. Print each recipe step on a new line.
            2. Do not use bullets, numbers, hyphens, and any other symbols.
            3. Only print the recipe steps.
            4. Do not include any additional ingredients in the recipe steps.

            For example if the ingredients are almonds, flour, milk, eggs the output should be:
            Preheat your oven to 350°F (175°C).\nGrind almonds into a fine flour using a food processor or coffee grinder.
            \nIn a bowl, whisk together the almond flour, milk, and eggs until well combined.\nPour the mixture\ninto a baking dish.
            \nBake for 25-30 minutes, or until the pudding is set and golden brown on top.\nLet it cool before serving. Enjoy!"""
        },
        {
            "role": "user",
            "content": f"Generate a recipe for {dish_title} using {num_ingredients} ingredients which are {ingredients}"
        }
    ]

    return call_llm(messages, model)


def gen_recipe_title(topic, model):
    messages = [
        {
            "role": "system",
            "content": """You are a recipe title generator. Your task is to create a concise and engaging dish title based on the ingredients provided by the user. Follow these rules strictly:
                1. Collect the ingredients from the user.
                2. Generate a dish title that is no more than 5 words long.
                3. Only print the title without any quotation marks.
                4. Do not include the list of ingredients, steps, markdowns or any additional information.
                5. Do not include any text, quotes or anything before and after the title.

                For example, if the ingredients are "chicken, garlic, lemon", a valid output could be:

                Lemon Garlic Chicken
                """
        },
        {"role": "user", "content": topic}
    ]

    return call_llm(messages, model)


def gen_num_ingredients(topic, model):
    messages = [
        {
            "role": "system",
            "content": """You are a mathematical counter. Count the number of ingredients entered by the user. Strictly follow these rules:
                1. Only print the number of ingredients.
                2. The output should be a number.
                2. Do not print title, steps and additional information.
                3. Do not print the word 'ingredients' and '.'"""
        },
        {"role": "user", "content": topic}
    ]

    return call_llm(messages, model)


def gen_ingredient_list(topic, model):
    messages = [
        {
            "role": "system",
            "content": """You are a list maker. Your task is to create a list of ingredients entered by the user. Follow these rules strictly:
                1. Print each ingredient on a new line.
                2. Do not use bullets, numbers, hyphens, or any other symbols.
                3. The first letter of each ingredient should be capitalized.
                4. Do not include any additional ingredients, dish titles, recipe steps, or extra information.
                5. Do not add any introductory or concluding text. Only list the ingredients as provided by the user.

                For example, if the user enters "eggs, flour, milk", the output should be:

                Eggs
                Flour
                Milk

                Ensure that all provided ingredients are listed exactly as specified."""
        },
        {
            "role": "user",
            "content": f"Find ingredients from the following prompt: {topic}"
        }
    ]

    return call_llm(messages, model)


def gen_description(dish_title, ingredients, recipe, model):
    messages = [
        {
            "role": "system",
            "content": """You generate descriptions for recipes. Follow these rules strictly:
                1. Do not add quotes to generated text.
                2. Keep the description brief and do not include the recipe steps.
                3. Limit the description length to 4-5 sentences."""
        },
        {
            "role": "user",
            "content": f"""Generate a description for the dish with the following details.
            Dish name: {dish_title}, Ingredients needed: {ingredients} and Recipe: {recipe}"""
        }
    ]

    return call_llm(messages, model)


def get_cover_image(dish_title):
    url = "https://google.serper.dev/images"
    serper_headers = {
        "X-API-KEY": SERPER_API_KEY,
        "Content-Type": "application/json"
    }
    data = {"q": dish_title}
    response = requests.post(url, headers = serper_headers, json = data)
    response_data = response.json()
    return response_data["images"][0]["imageUrl"]


def get_emoji(dish_title, model):
    messages = [
        {
            "role": "system",
            "content": """List of emojis: 👌, 😋, 🍗,🍝,🍚,🍴,🍰,🍪,🍩,🍜,🍝,🍠,🍣,🍤,🦐,🥗,🍲,🥘,🥚,
            🥙,🍖,🥓,🍔,🍕,🥞,🥖,🥐,🍞,🫘,🥜,🫒,🥑,🍆,🥔,🥕,🥒,🍅,🥝,🍓,🍒,🍑,🍍,🍇,🍈,🍉,🍊.🍌\n
            Follow these rules strictly: \n
            1. The output should be exactly 1 emoji character.\n
            2. Do not include any text before and after the emoji."""
        },
        {
            "role": "user",
            "content": "I want one emoji for the dish titled Chicken Onion Sandwich. Choose one emoji from the list you have and return it."
        },
        {
            "role": "assistant",
            "content": "🍔"
        },
        {
            "role": "user",
            "content": "I want one emoji for the dish titled Chicken Onion Tomato. Choose one emoji from the list you have and return it."
        },
        {
            "role": "assistant",
            "content": "🍅"
        },
        {
            "role": "user",
            "content": "I want one emoji for the dish titled Caramelized Onion Pasta. Choose one emoji from the list you have and return it."
        },
        {
            "role": "assistant",
            "content": "🍝"
        },
        {
            "role": "user",
            "content": f"I want one emoji for the dish titled {dish_title}. Choose one emoji from the list you have and return it.",
        }
    ]

    return call_llm(messages, model)

These are the functions we define here:

  • gen_recipe, which receives a recipe title and ingredients, sets the system role to a recipe generator, queries the LLM to generate a recipe according to the provided ingredients, then returns the response from the LLM.
  • gen_recipe_title, which receives a list of ingredients, sets the system role to a recipe title generator, and queries the LLM for a simple recipe title incorporating those ingredients.
  • gen_num_ingredients, which receives a list of ingredients and queries the LLM to count the provided ingredients and return the count.
  • gen_ingredient_list, which receives the user input and queries the LLM to format the ingredients mentioned into a list.
  • gen_description, which receives the ingredients, the recipe, and the recipe title, sets the system role, and queries the model to generate a description for the provided recipe.
  • get_cover_image, which receives the recipe title, configures the Serper search headers, searches for images matching the title, and then returns the results.
  • get_emoji, which receives the recipe title, then provides the LLM with a list of emojis and tells it to pick an emoji according to the provided title.

All these functions use the call_llm function we defined earlier to process the calls to the Tune Studio API. We can now use these helper functions to generate complete recipes in Notion.

Generating recipes in Notion

Now we’ll write the main function, which receives the user input, generates the recipe using the helper functions, and saves it to Notion.

Add the following code to the main.py file:

main.py
def main(topic, page_id, model):
    if type(topic) == tuple:
        topic = ",".join(topic)

    num_ingredients = gen_num_ingredients(topic, model)
    dish_title = gen_recipe_title(topic, model)
    ingredients = gen_ingredient_list(topic, model)
    recipe = gen_recipe(dish_title, ingredients, num_ingredients, model)
    description = gen_description(dish_title, ingredients, recipe, model)

    ingredient = ingredients.split("\n")

    steps = recipe.split("\n")
    cleaned_steps = []

    for step in steps:
        if step.strip():
            cleaned_steps.append(step)

    data = {
        "parent": {"page_id": page_id},
        "properties": {"title": [{"text": {"content": dish_title.strip()}}]},
        "cover": {"external": {"url": get_cover_image(dish_title)}},
        "icon": {"emoji": get_emoji(dish_title, model)}
    }

    response = requests.post(
        "https://api.notion.com/v1/pages", headers=notion_headers, json=data
    )

    new_page_data = response.json()

    data = {
        "children": [
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [
                        {"type": "text", "text": {"content": "Dish Description"}}
                    ]
                }
            },
            {
                "object": "block",
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [
                        {
                            "type": "text",
                            "text": {"content": description.strip()}
                        }
                    ]
                }
            },
            {
                "object": "block",
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [
                        {"type": "text", "text": {"content": "Ingredients List"}}
                    ]
                }
            }
        ]
    }

    for item in ingredient:
        data["children"].append(
            {
                "object": "block",
                "type": "bulleted_list_item",
                "bulleted_list_item": {
                    "rich_text": [
                        {
                            "type": "text",
                            "text": {"content": item.strip()}
                        }
                    ]
                }
            }
        )

    data["children"].append(
        {
            "object": "block",
            "type": "heading_2",
            "heading_2": {
                "rich_text": [{"type": "text", "text": {"content": "Recipe"}}]
            }
        }
    )

    for step in cleaned_steps:
        data["children"].append(
            {
                "object": "block",
                "type": "numbered_list_item",
                "numbered_list_item": {
                    "rich_text": [{"type": "text", "text": {"content": step.strip()}}]
                }
            }
        )

    response = requests.patch(
        f"https://api.notion.com/v1/blocks/{new_page_data['id']}/children",
        headers=notion_headers,
        json=data
    )

Here’s what this function does:

  • It receives the user’s query, a Notion page ID, and the model name.
  • It calls the helper functions to count and list the ingredients, and generates a title, a recipe, and a description.
  • It does some basic reformatting of the recipe to get rid of newlines or blank spaces in the LLM output.
  • Next, it puts together a data dictionary containing the information needed to make a new page in the provided Notion page and uses requests.post to post this information to the Notion API.
  • Then it redefines the data dictionary to contain the information to be added to the recipe page, adds the recipe description and the headers for the ingredient list, loops through the ingredients list previously generated, and adds the ingredients as bullet points.
  • It loops through the cleaned_steps containing the cleaned recipe steps we defined earlier and appends the steps to the data dictionary.
  • Finally, it sends a PATCH request to the Notion API to update the recipe page with the collected information.

Running the application

We’ll use Fire to create a CLI for the application.

Add the following code to the main.py file:

main.py
if __name__ == "__main__":
    fire.Fire(main)

This code sets up a CLI we can use to interact with the script. You can test it out by running a command like the following:

python main.py --topic='give me a recipe using bread, chicken, onion' \
  --page_id=<your-notion-page-id> \
  --model=rohan/mixtral-8x7b-inst-v0-1-32k

You can now view the generated recipe in Notion.