Cook_with_a_LLM / app.py
FredinVázquez
Revert "Reapply "update UI""
fced97d
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,
)