"""
Mise โ AI Cooking Studio (Gradio app for a Hugging Face Space).
Warm feng-shui UI with provenance badges, recipe cards, a data dashboard, and
generation via the HF Inference API (+ template fallback) โ runs free on CPU/ZeroGPU.
"""
import json
import numpy as np
import pandas as pd
import gradio as gr
import requests
# --- Work around a gradio_client bug ---
import gradio_client.utils as _gcu
_gcu_orig = _gcu._json_schema_to_python_type
def _gcu_safe(schema, defs=None):
if isinstance(schema, bool):
return "Any"
return _gcu_orig(schema, defs)
_gcu._json_schema_to_python_type = _gcu_safe
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from huggingface_hub import hf_hub_download, InferenceClient
import faiss
from sentence_transformers import SentenceTransformer
import spaces
@spaces.GPU(duration=1)
def _gpu_stub():
return True
# ---------------- config ----------------
DATA_OWNER = "idoyaaran"
DATASET_REPO = f"{DATA_OWNER}/mise-recipes"
ARTIFACTS = f"{DATA_OWNER}/mise-artifacts"
GEN_MODEL = "Qwen/Qwen2.5-7B-Instruct"
IMAGE_MODEL = "black-forest-labs/FLUX.1-schnell"
CUISINE_EMOJI = {
"italian": "๐", "mexican": "๐ฎ", "mediterranean": "๐ซ", "indian": "๐",
"levantine": "๐ง", "comfort_food": "๐ฒ", "farm_to_table": "๐ฅ", "vegan": "๐ฅ",
}
DIET_EMOJI = {
"vegan": "๐ฑ", "vegetarian": "๐ฅฆ", "gluten_free": "๐พ", "dairy_free": "๐ฅ",
"nut_free": "๐ฅ", "contains_pork": "๐", "contains_shellfish": "๐ฆ", "spicy": "๐ถ๏ธ",
}
LEVELS = {1: "core local ingredients + kitchen safety", 2: "prep & knife skills",
3: "fundamental techniques", 4: "beginner signature dishes",
5: "intermediate/advanced dishes from scratch"}
LEVEL_NAME = {1: "Pantry & Basics", 2: "Prep & Knife Skills", 3: "Fundamental Techniques",
4: "Cuisine Staples", 5: "Signature Dishes"}
LEVEL_DIFFICULTY = {1: "beginner", 2: "beginner", 3: "intermediate", 4: "advanced", 5: "advanced"}
# ---------------- load data + build the recipe index (CPU) ----------------
df = pd.read_parquet(hf_hub_download(DATASET_REPO, "recipes_clean.parquet", repo_type="dataset"))
curricula = json.load(open(hf_hub_download(ARTIFACTS, "curricula.json", repo_type="model")))
client = InferenceClient()
CUISINES = sorted(df["cuisine"].unique().tolist())
WINNING_MODEL = "BAAI/bge-small-en-v1.5"
embedder = SentenceTransformer(WINNING_MODEL, device="cpu")
def recipe_text(r):
ings = ", ".join(i["name"] for i in r["ingredients"])
return f"{r['title']} | {r['cuisine']} {r['dish_type']} | ingredients: {ings} | techniques: {', '.join(r['techniques'])}"
_texts = [recipe_text(r) for r in df.to_dict("records")]
_emb = embedder.encode(_texts, batch_size=64, convert_to_numpy=True, show_progress_bar=False)
_emb = _emb / (np.linalg.norm(_emb, axis=1, keepdims=True) + 1e-9)
index = faiss.IndexFlatIP(_emb.shape[1]); index.add(_emb.astype("float32"))
# ---------------- recommendation ----------------
def recommend(query, k=10):
q_text = query
if "bge" in WINNING_MODEL.lower():
q_text = "Represent this sentence for searching relevant passages: " + query
q = embedder.encode([q_text], convert_to_numpy=True)
q = q / (np.linalg.norm(q, axis=1, keepdims=True) + 1e-9)
scores, idx = index.search(q.astype("float32"), k)
return df.iloc[idx[0]].to_dict("records"), scores[0]
def diet_pills(tags):
return " ".join(f"{DIET_EMOJI.get(t,'')} {t.replace('_',' ')}" for t in tags)
def recipe_card(r, score=None, generated=False):
emoji = CUISINE_EMOJI.get(r["cuisine"], "๐ฝ๏ธ")
ings = "".join(f"
{i['name']} {i.get('quantity','')}" for i in r["ingredients"])
steps = "".join(f"{s}" for s in r["steps"])
badge = ("๐ค AI-GENERATED" if generated
else "๐ RETRIEVED๐งช SYNTHETIC")
scorebar = ""
if score is not None:
pct = max(0, min(100, int(score * 100)))
scorebar = (f"")
return f"""
{emoji}
{r['title']}
{r['cuisine'].replace('_',' ')}
{badge}
{r['dish_type'].replace('_',' ')} ยท {r['difficulty']} ยท โฑ {r['time_minutes']} min ยท ๐ฝ {r['servings']}
{scorebar}
{diet_pills(r['dietary_tags'])}
Ingredients
Steps
{steps}
"""
# ---------------- generation ----------------
def template_recipe(cuisine, level):
path = list(curricula.get(cuisine, {}).values())
dishes = path[level - 1] if len(path) >= level else []
hint = f" Take inspiration from: {', '.join(dishes[:3])}." if dishes else ""
diff = LEVEL_DIFFICULTY[level]
return (f"**A {diff} {cuisine.replace('_',' ')} dish**\n\n"
f"1. Gather the core local ingredients of this cuisine.\n"
f"2. Practice the level's key technique.\n"
f"3. Cook, taste, and adjust seasoning as you go.{hint}\n\n"
f"*๐ก mise en place โ prep everything before the heat.*")
def ai_recipe(cuisine, level):
diff = LEVEL_DIFFICULTY[level]
prompt = (f"Write a {diff} {cuisine.replace('_',' ')} main-course recipe. "
f"Give a short title, an ingredient list, and numbered steps. Keep it under 180 words.")
try:
out = client.chat_completion(model=GEN_MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=420, temperature=0.7)
return out.choices[0].message.content.strip()
except Exception:
return None
def ai_image(query, cuisine):
prompt = (f"professional food photograph of a {cuisine.replace('_',' ')} dish, {query}, "
f"plated beautifully, appetizing, natural light, high detail")
try:
return client.text_to_image(prompt, model=IMAGE_MODEL)
except Exception:
return None
# ---------------- live data: weather ----------------
def get_weather_hint(city):
if not city or not city.strip():
return None, None, None
try:
geo = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city.strip(), "count": 1}, timeout=5,
).json()
if not geo.get("results"):
return None, None, None
loc = geo["results"][0]
place = f"{loc['name']}, {loc.get('country', '')}".strip(", ")
wx = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": loc["latitude"], "longitude": loc["longitude"],
"current": "temperature_2m,weather_code"},
timeout=5,
).json()
current = wx.get("current", {})
temp, code = current.get("temperature_2m"), current.get("weather_code")
if temp is None or code is None:
return None, None, None
SNOW = {71, 73, 75, 77, 85, 86}
RAIN = {51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82, 95, 96, 99}
if temp >= 24:
if code in RAIN:
hint = "a refreshing, light dish for a warm rainy day โ"
else:
hint = "a light, refreshing, cold dish like a salad or chilled drink โ๏ธ"
elif code in SNOW or temp <= 5:
hint = "a warm, comforting dish โ perfect for this freezing/snowy weather โ๏ธ"
elif code in RAIN or temp <= 15:
hint = "a warm, comforting dish โ perfect for this chilly rainy day ๐ง๏ธ"
else:
hint = "a comforting, hearty meal"
return f"{place}: {temp:.0f}ยฐC", hint, temp
except Exception as e:
print("Weather lookup failed:", e)
return None, None, None
# ============================================================
# Telegram bot
# ============================================================
import os
import threading
import telebot
def format_telegram_recipe(r, score=None):
emoji = CUISINE_EMOJI.get(r["cuisine"], "๐ฝ๏ธ")
lines = [
f"{emoji} *{r['title']}*",
f"_{r['cuisine'].replace('_',' ').title()} ยท {r['dish_type'].replace('_',' ')} ยท {r['difficulty']}_",
f"โฑ {r['time_minutes']} min ยท ๐ฝ serves {r['servings']}",
]
if score is not None:
lines.append(f"๐ match: {int(score * 100)}%")
lines += ["", "*Ingredients:*"]
for i in r["ingredients"]:
qty = i.get("quantity", "")
lines.append(f"โข {i['name']} {qty}".strip())
lines += ["", "*Steps:*"]
for idx, s in enumerate(r["steps"], 1):
lines.append(f"{idx}. {s}")
return "\n".join(lines)
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
if BOT_TOKEN:
bot = telebot.TeleBot(BOT_TOKEN)
@bot.message_handler(commands=["start", "help"])
def handle_start(message):
bot.reply_to(message, "๐ณ Hi! Tell me what you feel like cooking โ "
"e.g. 'a spicy vegan dinner' โ and I'll suggest a recipe.")
@bot.message_handler(func=lambda m: True)
def handle_message(message):
query = (message.text or "").strip()
if not query:
bot.reply_to(message, "Tell me what you feel like cooking!")
return
try:
recs, scores = recommend(query, k=1)
r, score = recs[0], float(scores[0])
except Exception as e:
bot.reply_to(message, "Sorry, something went wrong finding a recipe. Try again?")
print("Telegram recommend() error:", e)
return
bot.reply_to(message, format_telegram_recipe(r, score), parse_mode="Markdown")
img = ai_image(query, r["cuisine"])
if img is not None:
from io import BytesIO
buf = BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
bot.send_photo(message.chat.id, photo=buf)
def _run_bot():
print("Starting Telegram bot polling...")
bot.infinity_polling(skip_pending=True)
threading.Thread(target=_run_bot, daemon=True).start()
def make_lesson(cuisine, level):
level = int(level)
diff = LEVEL_DIFFICULTY[level]
header = (f"### ๐ {cuisine.replace('_',' ').title()} โ Level {level}: {LEVEL_NAME[level]}\n"
f"*Goal:* {LEVELS[level]}.\n\n"
f"**๐ค Your freshly generated {diff} dish to cook today:**\n\n")
body = ai_recipe(cuisine, level) or template_recipe(cuisine, level)
return header + body
# ---------------- handlers ----------------
def cook(query, city=""):
if not query or not query.strip():
return "Tell me what you feel like cooking above ๐
", None, "", ""
weather_desc, weather_hint, temp = get_weather_hint(city)
full_query = f"{query.strip()}. {weather_hint}" if weather_hint else query.strip()
# Fetch top 15 candidates for smart post-filtering
recs, scores = recommend(full_query, k=15)
filtered_pairs = []
for r, s in zip(recs, scores):
title = r["title"].lower()
# Hard post-filtering rules based on temp
if temp is not None and temp >= 24:
# Hot weather -> Filter out warm soups, stews, or explicit "warm" hot dishes
if any(w in title for w in ["soup", "warm", "stew", "hot"]):
continue
filtered_pairs.append((r, s))
if len(filtered_pairs) == 3:
break
# Fallback to initial matches if filter was too strict
if len(filtered_pairs) < 3:
filtered_pairs = list(zip(recs[:3], scores[:3]))
final_recs = [pair[0] for pair in filtered_pairs]
final_scores = [pair[1] for pair in filtered_pairs]
cards = "".join(recipe_card(r, s) for r, s in zip(final_recs, final_scores))
top = final_recs[0]["cuisine"]
img = ai_image(query, top)
lesson = make_lesson(top, 1)
weather_note = ""
if weather_desc and weather_hint:
weather_note = f"๐ฆ๏ธ *Current weather in {weather_desc} โ nudging toward {weather_hint}.*"
elif weather_desc:
weather_note = f"๐ฆ๏ธ *Current weather in {weather_desc}.*"
return f"{cards}
", img, lesson, weather_note
def explore(cuisine, difficulty, diet):
sub = df
if cuisine != "All": sub = sub[sub["cuisine"] == cuisine]
if difficulty != "All": sub = sub[sub["difficulty"] == difficulty]
if diet: sub = sub[sub["dietary_tags"].apply(lambda tags: all(d in tags for d in diet))]
rows = sub.head(40)
return pd.DataFrame({
"Recipe": rows["title"],
"Cuisine": rows["cuisine"].apply(lambda c: c.replace("_", " ")),
"Type": rows["dish_type"].apply(lambda d: d.replace("_", " ")),
"Difficulty": rows["difficulty"],
"Time": rows["time_minutes"].apply(lambda t: f"{t} min"),
})
def show_explorer_card(evt: gr.SelectData):
if evt.row_value is None:
return ""
title = evt.row_value[0]
match = df[df["title"] == title]
if match.empty:
return "Couldn't find that recipe.
"
r = match.iloc[0].to_dict()
return recipe_card(r, score=None, generated=False)
def learn(cuisine, level):
path = curricula.get(cuisine, {})
path_md = "\n".join(f"- **{lvl}** โ {', '.join(d) if d else 'โ'}" for lvl, d in path.items())
return f"#### ๐บ๏ธ {cuisine.replace('_',' ').title()} learning path\n{path_md}\n\n---\n" + make_lesson(cuisine, level)
# ---------------- dashboard charts ----------------
BG, PANEL, TEXT, TOMATO, BASIL, SAFFRON = "#f6efe3", "#fffdf8", "#4b3b2f", "#c56b4a", "#7e9b6e", "#d9a441"
def _style(ax):
ax.set_facecolor(PANEL)
for s in ax.spines.values(): s.set_color("#d8ccb6")
ax.tick_params(colors=TEXT, labelsize=8); ax.title.set_color(TEXT)
ax.xaxis.label.set_color(TEXT); ax.yaxis.label.set_color(TEXT)
def dashboard():
fig, axes = plt.subplots(1, 3, figsize=(15, 4.2)); fig.patch.set_facecolor(BG)
cc = df["cuisine"].value_counts()
axes[0].bar([c.replace('_',' ') for c in cc.index], cc.values, color=TOMATO); axes[0].set_title("Recipes per cuisine")
axes[0].tick_params(axis="x", rotation=45)
dd = df["difficulty"].value_counts()
axes[1].bar(dd.index, dd.values, color=SAFFRON); axes[1].set_title("Difficulty mix")
from collections import Counter
ing = Counter(i["name"].lower() for r in df["ingredients"] for i in r)
top = ing.most_common(10)[::-1]
axes[2].barh([t[0] for t in top], [t[1] for t in top], color=BASIL); axes[2].set_title("Top 10 ingredients")
for ax in axes: _style(ax)
plt.tight_layout()
return fig
STATS_HTML = f"""
{df['cuisine'].nunique()}
cuisines
{WINNING_MODEL.split('/')[-1]}
embedding model
"""
CSS = """
:root{--bg:#f6efe3;--panel:#fffdf8;--clay:#c56b4a;--sage:#7e9b6e;--ochre:#d9a441;--text:#4b3b2f;--muted:#93826f;--border:#e7dcc8;}
.gradio-container{max-width:1150px !important;background:var(--bg) !important;color:var(--text) !important;}
#hero{background:linear-gradient(135deg,#d98b62 0%,#e3bd76 100%);border-radius:20px;padding:26px 30px;margin-bottom:8px;color:#4b3b2f;}
#hero h1{margin:0;font-size:34px;font-weight:800;letter-spacing:.3px;}
#hero p{margin:6px 0 0;font-weight:600;opacity:.92;}
.stats{display:flex;gap:12px;flex-wrap:wrap;margin:12px 0;}
.stat{flex:1;min-width:150px;background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:14px 16px;}
.stat .num{font-size:22px;font-weight:800;color:var(--clay);}
.stat .lbl{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.5px;}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:14px;}
.card{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:15px 17px;box-shadow:0 6px 18px rgba(120,90,60,.10);}
.card.gencard{border:1px solid var(--ochre);box-shadow:0 8px 22px rgba(217,164,65,.18);}
.card-top{display:flex;align-items:center;gap:8px;}
.cemoji{font-size:20px;}
.ctitle{font-weight:800;font-size:16px;color:var(--text);flex:1;}
.cpill{background:var(--clay);color:#fff;border-radius:999px;padding:3px 10px;font-size:11px;white-space:nowrap;}
.prov-row{margin:8px 0 4px;}
.prov{border-radius:999px;padding:2px 9px;font-size:10px;letter-spacing:.4px;margin-right:5px;border:1px solid;}
.prov.ret{color:#5f7a52;border-color:#94ad84;}
.prov.syn{color:#a9772a;border-color:#e0bd7a;}
.prov.gen{color:#b0522f;border-color:#dc9877;}
.meta{color:var(--muted);font-size:12px;margin:4px 0;font-family:ui-monospace,monospace;}
.matchwrap{margin:8px 0;}
.matchlbl{font-size:11px;color:var(--muted);font-family:ui-monospace,monospace;}
.matchbar{height:7px;background:#ece2cf;border-radius:999px;overflow:hidden;margin-top:3px;}
.matchfill{height:100%;background:linear-gradient(90deg,var(--sage),var(--ochre));}
.diet .pill{display:inline-block;background:#f0e7d5;color:var(--muted);border:1px solid var(--border);border-radius:999px;padding:2px 8px;font-size:11px;margin:2px 3px 0 0;}
.card .q{color:var(--clay);font-size:12px;}
details summary{cursor:pointer;font-weight:700;color:var(--clay);margin-top:7px;font-size:13px;}
.ing,.steps{color:var(--text);font-size:13px;}
.hint{color:var(--muted);}
"""
with gr.Blocks(css=CSS, title="Mise โ AI Cooking Studio", theme=gr.themes.Soft()) as demo:
gr.HTML("๐ณ Mise โ AI Cooking Studio
"
"
Tell me what you feel like cooking. I retrieve 3 real recipes and generate a fresh one to learn from.
")
gr.HTML(STATS_HTML)
with gr.Tab("๐ณ Cook"):
with gr.Row():
query = gr.Textbox(label="Let's make a ___ dish", placeholder="e.g. a spicy vegan dinner", scale=3)
city = gr.Textbox(label="Your city (optional โ live weather)", placeholder="e.g. Tel Aviv", scale=1)
gr.Examples(["a spicy vegan dinner", "easy mexican street food", "a comforting italian pasta"],
inputs=query, label="Quick starters")
btn = gr.Button("Find recipes + generate one ๐ฝ๏ธ", variant="primary")
weather_note = gr.Markdown()
cards = gr.HTML()
gen_img = gr.Image(label="๐ค Your AI-generated dish", height=340)
lesson = gr.Markdown()
btn.click(cook, [query, city], [cards, gen_img, lesson, weather_note])
query.submit(cook, [query, city], [cards, gen_img, lesson, weather_note])
with gr.Tab("๐ Cuisine Explorer"):
with gr.Row():
f_cuisine = gr.Dropdown(["All"] + CUISINES, value="All", label="Cuisine")
f_diff = gr.Dropdown(["All", "beginner", "intermediate", "advanced"], value="All", label="Difficulty")
f_diet = gr.Dropdown(list(DIET_EMOJI.keys()), label="Dietary (pick any)", multiselect=True)
ex_btn = gr.Button("Filter recipes ๐", variant="primary")
table = gr.Dataframe(interactive=False, type="pandas")
explorer_card_out = gr.HTML()
ex_btn.click(explore, [f_cuisine, f_diff, f_diet], table)
table.select(show_explorer_card, None, explorer_card_out)
with gr.Tab("๐ Learn to Cook"):
with gr.Row():
l_cuisine = gr.Dropdown(CUISINES, value=CUISINES[0], label="Cuisine")
l_level = gr.Slider(1, 5, step=1, value=1, label="Level")
l_btn = gr.Button("Show my lesson ๐", variant="primary")
l_out = gr.Markdown()
l_btn.click(learn, [l_cuisine, l_level], l_out)
with gr.Tab("๐ Kitchen Dashboard"):
gr.Markdown("Exploratory view of the 10,000-recipe synthetic dataset (Part 2 EDA).")
gr.Plot(value=dashboard())
gr.HTML(""
"Mise ยท synthetic recipe dataset + 3-model embedding benchmark + FAISS + fine-tuned generator ยท Reichman University
")
if __name__ == "__main__":
demo.launch(ssr_mode=False)