mise / app.py
idoyaaran's picture
Update app.py
16190d5 verified
Raw
History Blame Contribute Delete
20.9 kB
"""
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"<span class='pill diet'>{DIET_EMOJI.get(t,'')} {t.replace('_',' ')}</span>" for t in tags)
def recipe_card(r, score=None, generated=False):
emoji = CUISINE_EMOJI.get(r["cuisine"], "🍽️")
ings = "".join(f"<li>{i['name']} <span class='q'>{i.get('quantity','')}</span></li>" for i in r["ingredients"])
steps = "".join(f"<li>{s}</li>" for s in r["steps"])
badge = ("<span class='prov gen'>🤖 AI-GENERATED</span>" if generated
else "<span class='prov ret'>🔎 RETRIEVED</span><span class='prov syn'>🧪 SYNTHETIC</span>")
scorebar = ""
if score is not None:
pct = max(0, min(100, int(score * 100)))
scorebar = (f"<div class='matchwrap'><span class='matchlbl'>match {pct}%</span>"
f"<div class='matchbar'><div class='matchfill' style='width:{pct}%'></div></div></div>")
return f"""
<div class="card {'gencard' if generated else ''}">
<div class="card-top">
<span class="cemoji">{emoji}</span>
<span class="ctitle">{r['title']}</span>
<span class="cpill">{r['cuisine'].replace('_',' ')}</span>
</div>
<div class="prov-row">{badge}</div>
<div class="meta">{r['dish_type'].replace('_',' ')} · {r['difficulty']} · ⏱ {r['time_minutes']} min · 🍽 {r['servings']}</div>
{scorebar}
<div class="diet">{diet_pills(r['dietary_tags'])}</div>
<details><summary>Ingredients</summary><ul class="ing">{ings}</ul></details>
<details><summary>Steps</summary><ol class="steps">{steps}</ol></details>
</div>"""
# ---------------- 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 "<p class='hint'>Tell me what you feel like cooking above 👆</p>", 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"<div class='cards'>{cards}</div>", 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 "<p class='hint'>Couldn't find that recipe.</p>"
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"""
<div class='stats'>
<div class='stat'><div class='num'>{len(df):,}</div><div class='lbl'>recipes</div></div>
<div class='stat'><div class='num'>{df['cuisine'].nunique()}</div><div class='lbl'>cuisines</div></div>
<div class='stat'><div class='num'>{WINNING_MODEL.split('/')[-1]}</div><div class='lbl'>embedding model</div></div>
<div class='stat'><div class='num'>FAISS</div><div class='lbl'>similarity search</div></div>
</div>"""
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("<div id='hero'><h1>🍳 Mise — AI Cooking Studio</h1>"
"<p>Tell me what you feel like cooking. I retrieve 3 real recipes and generate a fresh one to learn from.</p></div>")
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("<p style='text-align:center;color:#b9a894;font-size:12px;margin-top:14px;'>"
"Mise · synthetic recipe dataset + 3-model embedding benchmark + FAISS + fine-tuned generator · Reichman University</p>")
if __name__ == "__main__":
demo.launch(ssr_mode=False)