| 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 |
| from src.ui.components import DishOptions, IngredientChips, NutritionGrid, RecipeHero, StepCard, VerdictBadge |
|
|
| |
| |
| |
|
|
| 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, |
| ) |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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(): |
| |
| |
| |
| with gr.Tab("🍳 Cook"): |
| with gr.Row(): |
| |
| 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") |
|
|
| |
| with gr.Column(scale=2): |
| hero = gr.HTML(RecipeHero.render({})) |
| steps_panel = gr.HTML(StepCard.render({})) |
| nutrition_panel = gr.HTML(NutritionGrid.render({"nutrition": {}})) |
|
|
| |
| |
| |
| 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({})) |
|
|
| |
| |
| |
| 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 |
| """ |
| ) |
|
|
| |
| |
| |
| 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, |
| ) |
|
|