""" 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"
    match {pct}%" 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"""
    {len(df):,}
    recipes
    {df['cuisine'].nunique()}
    cuisines
    {WINNING_MODEL.split('/')[-1]}
    embedding model
    FAISS
    similarity search
    """ 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)