Software Journal
Web Development Updated Sep 2, 2026 16 min read

AI Recipe Generator: Flask, LangChain & Gemini Guide

Flask AI recipe generator built with Python, LangChain and Gemini 1.5 Flash — how ingredient parsing, prompt engineering, and LCEL chains work end to end.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
AI Recipe Generator: Flask, LangChain & Gemini Guide

AI Recipe Generator: Flask, LangChain & Gemini Guide

A verified technical deep-dive into azario0/AI-Recipe-Generator — a Flask web app that turns a list of everyday ingredients into 2-3 chef-style recipes using LangChain and Google Gemini 1.5 Flash.

If you searched for “Flask AI recipe generator,” “LangChain Gemini recipe app,” “Python recipe generator with Gemini 1.5 Flash,” or “how to build an AI recipe app with Flask,” this article breaks down a working implementation file-by-file, with every claim verified against the actual repository code.

TL;DR – What This Project Does

Type ingredients like eggs, butter, flour, milk into a form at /, submit, and the app:

  1. Cleans and normalizes them with a regex splitter
  2. Injects them into an engineered prompt template
  3. Invokes a LangChain LCEL chain prompt | llm | parser
  4. Returns structured, markdown-like recipes with names, times, difficulty, steps, extras, and pro tips — rendered inside a <pre> block

No database, no JavaScript framework, no auth — just app/app.py, app/templates/index.html, app/static/style.css, and notebook.ipynb for prototyping.

Programming Languages Used

This repo is a compact polyglot web project:

LanguageRoleWhere
Python 3All backend logic: Flask routing, LangChain orchestration, ingredient parsingapp/app.py#L1-L152, notebook.ipynb
HTML (Jinja2)Single-page template with conditional error/recipe blocksapp/templates/index.html
CSSMinimal responsive styling, no frameworkapp/static/style.css
Jupyter Notebook (IPython)Prototyping and interactive REPLnotebook.ipynb

There is no custom JavaScript, no TypeScript, no database migration, and no ORM. Python does the heavy lifting; Jinja2 outputs static HTML.

Technology Stack at a Glance

LayerTechnologyPurposeVerified In
Web FrameworkFlaskHTTP server, GET/POST / routing, render_templateapp/app.py#L3, requirements.txt#L1
LLMGoogle Gemini 1.5 Flash via langchain-google-genaiFast, cost-effective generation (temperature=0.7, max_tokens=1500 in app)app/app.py#L4, app/app.py#L20-L24
OrchestrationLangChain Core — ChatPromptTemplate, StrOutputParser, LCEL |Declarative `promptllm
Configurationpython-dotenvLoads GEMINI_API_KEY + PORT from .envapp/app.py#L7, requirements.txt#L4
FrontendJinja2 + HTML5/CSS3Form, error banner, <pre> recipe outputapp/templates/index.html
Prod ServergunicornWSGI server for deploymentrequirements.txt#L5

Project Structure

Verified with ls -R on the cloned repo:

AI-Recipe-Generator/
├── app/
│   ├── app.py               # 152-line Flask app + RecipeGenerator class
│   ├── templates/
│   │   └── index.html       # 34-line single page (form + output)
│   ├── static/
│   │   └── style.css        # 94-line vanilla CSS
│   ├── .env                 # GEMINI_API_KEY='YOUR_API_KEY' (placeholder)
│   └── README.md            # 41-line lightweight docs
├── notebook.ipynb           # 212 lines, 5 cells: prototype + REPL
└── requirements.txt         # 5 deps: Flask, langchain-google-genai, langchain-core, python-dotenv, gunicorn

Note: The root README.md is currently absent beyond the app/ one — the repo ships with 7 tracked files total (git log shows single commit d5a5a68 final).

Architecture Overview — How the Pieces Fit Together

Browser (GET /)                Flask app.py                     LangChain + Gemini
     |                              |                                   |
     |-- GET / -------------------> index() renders index.html ----------|
     |<-- HTML form ---------------|                                   |
     |                              |                                   |
     |-- POST / (ingredients) ---> index() POST                         |
     |                         -> clean_ingredients() [regex]            |
     |                         -> chain.invoke({ingredients}) --------> ChatPromptTemplate
     |                         |                                  -> ChatGoogleGenerativeAI
     |                         |                                  -> StrOutputParser
     |                         |<-- string recipes ---------------------|
     |<-- render index.html (recipes=<pre>) ---------------------------|
  • Stateless: No session, no DB. Each POST is self-contained.
  • Global singleton: recipe_generator_instance is created once at import time in app/app.py#L101-L116 — not per-request.
  • Error-first: initialization_error is passed to the template even on GET so a missing API key shows immediately in the .error banner (see Flask Route Deep Dive).

Deep Dive 1: Environment & Model Initialization

Loading Config — app/app.py#L7-L10

from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)

load_dotenv() reads .env from the current working directory. The checked-in app/.env contains GEMINI_API_KEY = 'YOUR_API_KEY' as a placeholder — you must replace it before running.

initialize_model(api_key) — app/app.py#L16-L25

def initialize_model(api_key):
    if not api_key:
        raise ValueError("API key for Gemini not found. Please set GEMINI_API_KEY environment variable.")
    return ChatGoogleGenerativeAI(
        model="gemini-1.5-flash",
        google_api_key=api_key,
        temperature=0.7,
        max_tokens=1500
    )
  • Model: gemini-1.5-flash — the fastest Gemini 1.5 variant, ideal for low-latency web apps.
  • Temperature 0.7: Balanced creativity; not deterministic (0.0) nor wild (1.0), so “eggs, flour” can yield both pancakes and crêpes.
  • Max tokens: 1500 in the Flask app vs. 1000 in the notebook’s notebook.ipynb#L46 — the app comment explicitly says “Increased max_tokens for potentially longer recipes.” This allows 2-3 full recipes without truncation.

The notebook version in notebook.ipynb Cell 1 is identical except for the token count — verified by diffing the two definitions.

Deep Dive 2: Prompt Engineering — The Chef Persona

create_recipe_prompt() — app/app.py#L27-L58

This is the most important function for output quality. It returns a ChatPromptTemplate built from a multi-section string:

prompt_template = """You are a professional chef...
CONTEXT: The user will provide a list of ingredients...
INSTRUCTIONS:
1. Analyze the provided ingredients carefully.
2. Suggest 2-3 different recipe options that use most or all of the provided ingredients.
3. For each recipe, provide:
   - Recipe name (bolded or clearly marked)
   - Brief description (1-2 sentences)
   - Cooking time and difficulty level
   - Step-by-step instructions (numbered)
   - Any additional common ingredients ... (if none are needed beyond basics).
   - Pro tips for best results.
GUIDELINES:
- Prioritize recipes that use the maximum number of provided ingredients.
- Consider different cooking methods (frying, baking, boiling, etc.).
- Include recipes from various cuisines when possible.
- Be creative but practical.
- Assume basic pantry staples are available (salt, pepper, oil, water, common spices unless very specific).
- If ingredients seem limited, suggest simple but delicious preparations.
- Ensure the output is well-formatted with clear headers (e.g., using markdown-like bolding ...) and bullet points/numbered lists ...
AVAILABLE INGREDIENTS: {ingredients}
Please format your response clearly. Start immediately with the first recipe.
RESPONSE:"""
return ChatPromptTemplate.from_template(prompt_template)

Why it works:

  • Role grounding: “professional chef and culinary expert” improves domain specificity.
  • Context block: Explicitly frames the task as using provided ingredients, reducing hallucinations of unavailable items.
  • Number constraint: 2-3 recipe options keeps the response bounded for max_tokens=1500.
  • Structured output contract: Six bullet requirements per recipe force a predictable parse for the <pre> rendering — no JSON needed, just formatted text.
  • Guidelines: The maximum ingredients + various cuisines heuristics push diversity (e.g., eggs + rice → fried rice and omelette).
  • Pantry assumption: Prevents the model from listing salt/pepper as missing.
  • Formatting hint: “markdown-like bolding” + “Start immediately with the first recipe” ensures the StrOutputParser string can be dropped directly into <pre> without preamble.

The notebook’s prompt at notebook.ipynb Cell 2 is similar but slightly shorter (no “explicitly state if none are needed beyond basics” or “distinct sections” phrasing) — the Flask version is the refined production prompt.

Deep Dive 3: The RecipeGenerator Class

Class Skeleton — app/app.py#L60-L96

class RecipeGenerator:
    def __init__(self, api_key):
        try:
            self.llm = initialize_model(api_key)
            self.prompt = create_recipe_prompt()
            self.output_parser = StrOutputParser()
            self.chain = self.prompt | self.llm | self.output_parser
        except ValueError as e:
            print(f"Error during RecipeGenerator initialization: {e}")
            self.llm = None
            self.chain = None
            raise
  • LCEL chain: self.chain = self.prompt | self.llm | self.output_parser at app/app.py#L66 is the modern LangChain Expression Language pattern — no deprecated LLMChain or SequentialChain.
  • Fail-fast: If initialize_model raises ValueError (missing key), it logs, nulls llm/chain, and re-raises so the global initializer at L111-L113 can capture initialization_error.

clean_ingredients() — app/app.py#L74-L78

def clean_ingredients(self, ingredients_input):
    ingredients = re.split(r'[,;]|\sand\s', ingredients_input.lower().strip())
    cleaned = [ing.strip() for ing in ingredients if ing.strip()]
    return cleaned
  • Regex: r'[,;]|\sand\s' splits on , or ; or the word and surrounded by spaces (\s ensures "candy" doesn’t split). The r'[,;]|\sand\s' literal in code is r'[,;]|\sand\s' — note the escaped \s in the string representation.
  • Normalization: lower().strip() makes " Eggs AND Milk " → ["eggs", "milk"].
  • Idempotent: Tested in notebook REPL: eggs, butter and flour; milk → ["eggs", "butter", "flour", "milk"] → rejoined as "eggs, butter, flour, milk" at L89 before LLM call.

generate_recipes() — app/app.py#L80-L96

def generate_recipes(self, ingredients_input):
    if not self.chain:
        return "❌ Error: Recipe generator model not initialized. Please check API key."
    try:
        cleaned_ingredients = self.clean_ingredients(ingredients_input)
        if not cleaned_ingredients:
            return "⚠️ Please provide some ingredients."
        ingredients_str = ", ".join(cleaned_ingredients)
        response = self.chain.invoke({"ingredients": ingredients_str})
        return response
    except Exception as e:
        print(f"Error in generate_recipes: {e}")
        return f"❌ Error generating recipes: {str(e)}"
  • Guard order: Checks self.chain existence before any work — returns the exact string the route checks with startswith("❌ Error") at app/app.py#L139.
  • Empty input handling: Returns ⚠️ warning string, not an exception — the Flask route treats it as an error banner, not a recipe.
  • Invocation: self.chain.invoke({"ingredients": ingredients_str}) matches the {ingredients} placeholder in the prompt.

The notebook’s version at notebook.ipynb Cell 3 adds debug prints (🥘 Generating recipes for: and = separators) and lacks the if not self.chain guard — those are stripped in the production app for cleaner HTTP responses.

interactive_session() — Notebook Only

Defined only in notebook.ipynb Cell 3, this while True: REPL loop prompts 📝 Enter your available ingredients:, exits on quit/exit/q, handles empty input with ⚠️ Please enter some ingredients!, and prints = separators around output. It’s not in app.py — the Flask route replaces it for the web.

Deep Dive 4: Flask App Setup & Global Initialization

Singleton Pattern — app/app.py#L98-L116

API_KEY = os.getenv("GEMINI_API_KEY")
recipe_generator_instance = None
initialization_error = None
try:
    if API_KEY:
        recipe_generator_instance = RecipeGenerator(api_key=API_KEY)
    else:
        initialization_error = "GEMINI_API_KEY not found in environment variables..."
        print(f"Error: {initialization_error}")
except ValueError as e:
    initialization_error = str(e)
except Exception as e:
    initialization_error = f"An unexpected error occurred during initialization: {e}"
  • Once at import: The RecipeGenerator is instantiated at module load, not per-request — saving 1-2 seconds of model init per hit.
  • Graceful degradation: If API_KEY is falsy or RecipeGenerator.__init__ raises, initialization_error holds a user-facing string that the index() route always passes to the template at L123 (error_message = initialization_error). This is why the page shows a red banner even on GET when the key is missing.

Deep Dive 5: Flask Routing & Request Lifecycle

Single Route index() — app/app.py#L120-L146

@app.route('/', methods=['GET', 'POST'])
def index():
    recipes_output = None
    error_message = initialization_error
    submitted_ingredients = ''
    if not recipe_generator_instance and not initialization_error:
        error_message = "Recipe generator could not be initialized. Check server logs."
    if request.method == 'POST' and recipe_generator_instance:
        ingredients = request.form.get('ingredients')
        submitted_ingredients = ingredients
        if not ingredients or not ingredients.strip():
            error_message = "Please enter some ingredients."
        else:
            recipes_output = recipe_generator_instance.generate_recipes(ingredients)
            if recipes_output.startswith("❌ Error") or recipes_output.startswith("⚠️"):
                error_message = recipes_output
                recipes_output = None
    return render_template('index.html', recipes=recipes_output, error=error_message, submitted_ingredients=submitted_ingredients)

Flow, verified:

  1. GET /: recipes_output=None, error_message=initialization_error (if any), renders templates/index.html with empty form.
  2. POST /: Only enters the block if recipe_generator_instance exists (i.e., key was valid at startup). Retrieves request.form.get('ingredients') at L131, preserves it in submitted_ingredients for textarea repopulation.
  3. Validation: Empty or whitespace-only → error_message = "Please enter some ingredients." at L135.
  4. Generation: Calls generate_recipes(ingredients) at L138. That method already cleans, so the raw string is fine.
  5. Error routing: If the LLM returns a string starting with ❌ or ⚠️ (from L83 or L87 or L96), it is moved from recipes_output to error_message and recipes_output is cleared to None at L140-L141. The template’s {% if error %} vs {% if recipes %} mutually excludes the two blocks.
  6. Render: Always returns render_template('index.html', ...) at L143 with three context variables.

No PRG redirect: The app renders directly on POST (no redirect), so refreshing after submit will re-POST — a conscious simplicity trade-off.

App Runner — app/app.py#L148-L152

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5000)))
  • host='0.0.0.0' exposes to LAN/Docker.
  • PORT env var defaults to 5000 — matches gunicorn docs and PaaS conventions (Heroku/Render).

Deep Dive 6: Frontend — Jinja Template

index.html — app/templates/index.html (34 lines)

<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
...
<p class="info">Enter the ingredients you have, separated by commas, semicolons, or 'and'.</p>
{% if error %}
    <div class="error">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('index') }}">
    <textarea id="ingredients" name="ingredients" rows="4" required>{{ submitted_ingredients or '' }}</textarea>
    <input type="submit" value="Generate Recipes">
</form>
{% if recipes %}
<div class="recipes-output">
    <h2>Suggested Recipes:</h2>
    <pre>{{ recipes }}</pre>
</div>
{% endif %}
  • No JavaScript: Pure HTML form POST + server render.
  • Sticky input: {{ submitted_ingredients or '' }} at L21 repopulates the textarea after submit — verified in the route at L132.
  • Conditional blocks: {% if error %} at L14 shows the red banner from .error; {% if recipes %} at L26 shows the grey recipe card. They never appear together due to the error vs recipes split in the route.
  • Preformatted output: <pre>{{ recipes }}</pre> at L29 preserves LLM newlines/bullets with white-space: pre-wrap from CSS — no Markdown parser needed.

Deep Dive 7: Styling — Vanilla CSS

app/static/style.css is 94 lines, no preprocessor:

  • Layout: body with Sego UI, background:#f4f4f4, padding:20px at L1-L8; .container centered max-width:800px, box-shadow, border-radius:8px at L10-L17.
  • Form: textarea full-width, min-height:80px, box-sizing:border-box at L31-L39; submit button background:#5cb85c with hover #4cae4c at L41-L55 — a classic Bootstrap-success green.
  • Output: .recipes-output light grey #e9e9e9 with border, pre with white-space:pre-wrap, word-wrap:break-word, monospace Consolas at L57-L79 — ensures long lines wrap without horizontal scroll.
  • States: .error red text on #ffebee with #ef9a9a border at L81-L88; .info muted #555 at L90-L94.

Linking is via url_for('static', filename='style.css') in the template — Flask’s standard static routing, verified to work because static/ sits next to app.py under the app package.

Deep Dive 8: Notebook Prototype vs Production App

The notebook.ipynb is not just docs — it’s the original implementation with 5 executable cells:

CellContentKey Difference vs app.py
0API_KEY_GEMINI = 'YOUR_API_KEY'Hardcoded variable vs os.getenv("GEMINI_API_KEY") + load_dotenv() in app
1initialize_model()max_tokens=1000 vs 1500 in app/app.py#L24
2create_recipe_prompt()Shorter prompt — missing “explicitly state if none are needed beyond basics” and “distinct sections” phrasing added in app’s refined version
3RecipeGenerator + interactive_session()Notebook includes interactive_session() REPL loop (with input(), quit/exit/q handling) not present in Flask; Flask’s generate_recipes() adds if not self.chain guard and ⚠️ empty-input check
4recipe_gen = RecipeGenerator(API_KEY_GEMINI) + example eggs, butter, flour, milkImmediate demo — the same ingredients used as the app’s canonical example

This progression shows a clean prototype → product path: test the chain interactively, then wrap it in a singleton + Flask route.

End-to-End Flow — Concrete Example

Input: eggs, butter and flour; milk submitted from index.html#L21

  1. POST / hits app/app.py#L130-L138: request.form.get('ingredients') → "eggs, butter and flour; milk"
  2. RecipeGenerator.generate_recipes() at L84-L92:
    • clean_ingredients() → ["eggs", "butter", "flour", "milk"] via re.split
    • ingredients_str = "eggs, butter, flour, milk" (notebook cell 4 uses this exact string)
    • self.chain.invoke({"ingredients": ingredients_str}) → ChatPromptTemplate fills {ingredients}, ChatGoogleGenerativeAI calls gemini-1.5-flash at 0.7/1500, StrOutputParser returns plain string
  3. Response string (e.g., **1. Classic Pancakes**\nDescription: ...) is returned to index() at L138
  4. render_template('index.html', recipes=response, submitted_ingredients="eggs, butter and flour; milk") renders:
    • Textarea repopulated with the raw input
    • <div class="recipes-output"><pre>**1. Classic Pancakes**... visible
  5. User sees green submit button + recipe card with monospace formatting from style.css#L70-L79

If the API key was missing, step 2 never runs — initialization_error at L109 is rendered as <div class="error">GEMINI_API_KEY not found...</div> on the initial GET.

Installation & Verification — Exact Steps

Verified against app/README.md and requirements.txt:

git clone https://github.com/azario0/AI-Recipe-Generator.git
cd AI-Recipe-Generator
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

pip install -r requirements.txt
# installs: Flask, langchain-google-genai, langchain-core, python-dotenv, gunicorn

# Create env file — NOTE PATH: app/.env (not root) because app/app.py runs with app/ as CWD
echo 'GEMINI_API_KEY="YOUR_REAL_KEY"' > app/.env
# optional: echo 'PORT=5000' >> app/.env

python app/app.py
# → Running on http://0.0.0.0:5000
# → Open http://127.0.0.1:5000

# Or via gunicorn for prod:
gunicorn --chdir app app:app --bind 0.0.0.0:5000

Get a free key at aistudio.google.com/app/apikey — the app’s server log will print Error: GEMINI_API_KEY not found... at startup if misconfigured (see L110).

Customization & Extension Points

  • Change recipe count/style: Edit the prompt in app/app.py#L35-L42 — e.g., Suggest 5 vegan options or Provide only baking recipes.
  • Adjust creativity: Lower temperature=0.7 to 0.3 for deterministic results at L23.
  • Allow more ingredients/output: Raise max_tokens=1500 at L24 for longer outputs.
  • Support new separators: Modify the regex r'[,;]|\sand\s' at L76 — e.g., add |\n for line-separated lists.
  • Render Markdown: Replace <pre>{{ recipes }}</pre> in index.html#L29 with a Markdown filter (| markdown | safe) and add markdown to requirements.txt.
  • Add history: Store ingredients_str → recipes in Flask session or SQLite before render_template.
  • Add streaming: Swap StrOutputParser for a streaming parser and use chain.stream() with SSE — no frontend JS currently, so this requires adding fetch() + EventSource.

Known Limitations

Verified by reading edge cases, not marketing:

  • No input sanitization beyond splitting: Very long ingredient strings will hit Gemini token limits (no chunking like the sister RAG projects).
  • Single route, no PRG: Refresh after POST resubmits the form (browser warning).
  • No rate limiting or auth: Open endpoint — add Flask-Limiter before public deployment.
  • Plain <pre> output: No structured JSON; parsing recipes for a mobile app would require prompt changes.
  • Global singleton: recipe_generator_instance at L102 is not reloaded if the API key changes without restart.
  • No LICENSE or .gitignore at root: Commit includes .env placeholder — ensure .env is gitignored before adding real keys (the python-dotenv setup expects it).

Conclusion — Why This Repo Is a Great Flask + Gemini Starter

If you want to learn LangChain LCEL, prompt engineering, and Flask integration without the bloat, this project is a minimal, verified reference:

  • LangChain done right: ChatPromptTemplate | ChatGoogleGenerativeAI | StrOutputParser in 1 line (L66), prompt as code.
  • Flask done simply: One GET/POST route, one template, one CSS file — yet it handles singleton init, error-first UX, and sticky forms.
  • AI done practically: The clean_ingredients() regex and the 7 guideline bullets show that 80% of “AI quality” is just good input handling + prompt structure.

Clone it, add your GEMINI_API_KEY to app/.env, try tomatoes, basil, mozzarella, olive oil vs chicken and rice, and compare how temperature=0.7 balances creativity with reliability. From there, fork in dietary filters, Markdown rendering, or streaming — the chain is ready for it.


Links: app/app.py · notebook.ipynb · requirements.txt · app/templates/index.html · app/static/style.css · app/README.md

Verified against commit main on 2026-09-02. All file paths, line numbers, and parameter values checked against the cloned repository.

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.