| """ |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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"} |
|
|
| |
| 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")) |
|
|
| |
| 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>""" |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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() |
|
|
| |
| recs, scores = recommend(full_query, k=15) |
| |
| filtered_pairs = [] |
| for r, s in zip(recs, scores): |
| title = r["title"].lower() |
| |
| |
| if temp is not None and temp >= 24: |
| |
| if any(w in title for w in ["soup", "warm", "stew", "hot"]): |
| continue |
| |
| filtered_pairs.append((r, s)) |
| if len(filtered_pairs) == 3: |
| break |
|
|
| |
| 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) |
|
|
| |
| 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) |