import logging logging.basicConfig(level=logging.INFO) log = logging.getLogger(__name__) from typing import Any import gradio as gr from PIL import Image from src.agents.mise_en_place import identify_ingredients from src.agents.progress_validator import validate from src.agents.recipe_planner import plan_recipe, propose_dishes from src.agents.step_illustrator import illustrate_recipe from src.data.nutrition import compute_nutrition from src.ui.components import ( DishOptions, IngredientChips, NutritionGrid, RecipeHero, StepCard, VerdictBadge, ) from src.ui.theme import CSS, theme # --------------------------------------------------------------------------- # Callbacks # --------------------------------------------------------------------------- def _clean_ingredients(items: list | None) -> list[str]: """Normalize a raw ingredient list (dedup, lowercase, strip empties).""" out, seen = [], set() for it in (items or []): name = str(it).strip().lower() if name and name not in seen: seen.add(name) out.append(name) return out def on_propose(fridge_image: Image.Image | None, state: dict | None): """Photo → ingredients → 3 dish options (and fill the editable list).""" state = state or {} if fridge_image is None: return ( IngredientChips.render({}), DishOptions.render({}), gr.update(choices=[], value=None), state, gr.update(choices=[], value=[]), ) ingredients = identify_ingredients(fridge_image) options = propose_dishes(ingredients) state.update({ "ingredients_have": ingredients, "options": [o.model_dump() for o in options], }) radio_choices = [o.name for o in options] return ( IngredientChips.render({"have": ingredients, "missing": []}), DishOptions.render({"options": state["options"]}), gr.update(choices=radio_choices, value=radio_choices[0] if radio_choices else None), state, gr.update(choices=ingredients, value=ingredients), ) def on_update_ingredients(state: dict | None, ingredients: list | None): """Manual edit of the ingredient list → refresh chips + re-propose dishes.""" state = state or {} ingredients = _clean_ingredients(ingredients) state["ingredients_have"] = ingredients if not ingredients: state["options"] = [] return ( IngredientChips.render({}), DishOptions.render({}), gr.update(choices=[], value=None), state, ) options = propose_dishes(ingredients) state["options"] = [o.model_dump() for o in options] radio_choices = [o.name for o in options] return ( IngredientChips.render({"have": ingredients, "missing": []}), DishOptions.render({"options": state["options"]}), gr.update(choices=radio_choices, value=radio_choices[0] if radio_choices else None), state, ) def on_cook(state: dict | None, dish_name: str | None, illustrate: bool, ingredients: list | None): """Chosen dish → full recipe + nutrition (+ FLUX images if requested).""" state = state or {} if not dish_name: return ( RecipeHero.render({}), StepCard.render({}), NutritionGrid.render({"nutrition": {}}), state, ) # Prefer the (possibly hand-edited) ingredient list from the editor. ingredients = _clean_ingredients(ingredients) or state.get("ingredients_have", []) state["ingredients_have"] = ingredients recipe = plan_recipe(dish_name, ingredients) nutrition = compute_nutrition(ingredients, recipe.servings) recipe.nutrition = nutrition state["recipe"] = recipe.model_dump() if illustrate: log.info("Generating FLUX step images via Modal...") recipe = illustrate_recipe(recipe) state["recipe"] = recipe.model_dump() return ( RecipeHero.render(recipe.model_dump()), StepCard.render({"steps": [s.model_dump() for s in recipe.steps]}), NutritionGrid.render({"nutrition": nutrition}), state, ) def on_validate(state: dict | None, step_idx: float, progress_image: Image.Image | None): """Progress photo + step number → verdict badge.""" state = state or {} recipe = state.get("recipe", {}) steps = recipe.get("steps", []) idx = max(0, int(step_idx) - 1) instruction = steps[idx]["instruction"] if idx < len(steps) else "Cook the dish properly." result = validate(progress_image, instruction) return VerdictBadge.render(result) # --------------------------------------------------------------------------- # UI # --------------------------------------------------------------------------- def build_ui() -> gr.Blocks: initial_state: dict[str, Any] = {} with gr.Blocks(title="Cook With Me", theme=theme, css=CSS) as demo: gr.Markdown( "# 🍲 Cook With Me\n" "_Snap your fridge · Pick a dish · Cook step by step · Check your progress._" ) state = gr.State(initial_state) with gr.Tabs(): # ---------------------------------------------------------------- # Tab 1 — Cook # ---------------------------------------------------------------- with gr.Tab("🍳 Cook"): with gr.Row(): # Left — inputs with gr.Column(scale=1): fridge_input = gr.Image( label="📸 Photo of your fridge or pantry", type="pil", height=300, ) propose_btn = gr.Button("🔍 What can I cook?", variant="primary") gr.Markdown("### Ingredients I see") chips = gr.HTML(IngredientChips.render({})) ingredient_editor = gr.Dropdown( choices=[], value=[], multiselect=True, allow_custom_value=True, label="✏️ Add or remove ingredients (type + Enter to add, ✕ to remove)", interactive=True, ) update_btn = gr.Button("🔄 Update ingredients & dishes") gr.Markdown("### Pick a dish") dish_options_html = gr.HTML(DishOptions.render({})) dish_radio = gr.Radio( choices=[], label="Choose one", interactive=True, ) with gr.Accordion("⚙️ Generation options", open=False): illustrate_chk = gr.Checkbox( value=False, label="🎨 Generate step images with FLUX.2 (requires Modal deployment)", ) cook_btn = gr.Button("👨‍🍳 Build my recipe", variant="primary") # Right — recipe output with gr.Column(scale=2): hero = gr.HTML(RecipeHero.render({})) steps_panel = gr.HTML(StepCard.render({})) nutrition_panel = gr.HTML(NutritionGrid.render({"nutrition": {}})) # ---------------------------------------------------------------- # Tab 2 — Check Progress # ---------------------------------------------------------------- with gr.Tab("📷 Check Progress"): gr.Markdown( "Upload a photo of your pan or plate. The vision model compares it " "against the current recipe step and tells you if you can move on." ) with gr.Row(): with gr.Column(): step_idx = gr.Number(value=1, precision=0, label="Active step #") progress_input = gr.Image( label="📸 Your pan / plate", type="pil", height=300, ) validate_btn = gr.Button("✅ How am I doing?", variant="primary") with gr.Column(): verdict_panel = gr.HTML(VerdictBadge.render({})) # ---------------------------------------------------------------- # Tab 3 — About # ---------------------------------------------------------------- with gr.Tab("ℹ️ About"): gr.Markdown( """ ### How it works 1. **Snap** your fridge — the fine-tuned vision model (MiniCPM-V-4.6) identifies every ingredient. 2. **Pick** one of three AI-suggested dishes tailored to what you have. 3. **Cook** step by step with a generated recipe, per-serving nutrition, and optional FLUX.2 step images. 4. **Check** your progress — upload a photo of your pan and get a *go / wait / fix* verdict. ### Models | Role | Model | Params | |---|---|---| | Vision (ingredients + validator) | `openbmb/MiniCPM-V-4.6` (fine-tuned) | ~4.6B | | Recipe Planner | `openbmb/MiniCPM4.1-8B` (fine-tuned on Kaggle recipes) | ~8B | | Step Illustrator | `FLUX.2-klein-9B` via Modal | ~9B | **Total ≤ 21.6B params** (cap: 32B ✓) ### Badges targeted ✓ Well-Tuned · ✓ Off-Brand · ✓ Sharing is Caring · ✓ Field Notes ### Hackathon Hugging Face Small Models / Big Adventures · June 2026 · Track: Backyard AI """ ) # -------------------------------------------------------------------- # Wire callbacks # -------------------------------------------------------------------- propose_btn.click( fn=on_propose, inputs=[fridge_input, state], outputs=[chips, dish_options_html, dish_radio, state, ingredient_editor], ) update_btn.click( fn=on_update_ingredients, inputs=[state, ingredient_editor], outputs=[chips, dish_options_html, dish_radio, state], ) cook_btn.click( fn=on_cook, inputs=[state, dish_radio, illustrate_chk, ingredient_editor], outputs=[hero, steps_panel, nutrition_panel, state], ) validate_btn.click( fn=on_validate, inputs=[state, step_idx, progress_input], outputs=[verdict_panel], ) return demo if __name__ == "__main__": demo = build_ui() demo.launch( server_name="0.0.0.0", server_port=int(__import__("os").environ.get("PORT", 7860)), show_error=True, inbrowser=True, )