import gradio as gr import modal import io import sys import os import sqlite3 import concurrent.futures import soundfile as sf import numpy as np from PIL import Image as PILImage sys.path.append(os.path.dirname(__file__)) from utils import ( estimate_expiry, format_parsed_items, save_to_db, expiry_status, init_db ) os.makedirs("/data", exist_ok=True) DB_PATH = "/data/pantry.db" CHARACTERS = { "Grandma (Ammamma)": "Warm loving grandma who hates food waste.", "Chef": "Sharp professional chef. Direct and brilliant.", "Fitness Coach": "Enthusiastic fitness coach obsessed with gains.", "Food Critic": "Pompous but secretly warm food critic.", } CUISINES = ["South Indian", "North Indian", "Italian", "Mediterranean", "East Asian", "Surprise me"] # TTS backend: True = VoxCPMService class (OpenBMB VoxCPM2, full-recipe narration) # False = legacy "text_to_speech" function (Coqui XTTS-v2, dialogue only) USE_VOXCPM = True CHAR_EMOJI = { "Grandma (Ammamma)": "๐Ÿง“", "Chef": "๐Ÿ‘จโ€๐Ÿณ", "Fitness Coach": "๐Ÿ’ช", "Food Critic": "โญ" } def get_pantry_display(): init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("SELECT item_name, quantity, estimated_expiry FROM pantry WHERE used=0 ORDER BY estimated_expiry ASC") rows = c.fetchall() conn.close() if not rows: return "Your pantry is empty โ€” scan a receipt to get started." red = [r for r in rows if expiry_status(r[2])[1] <= 1] amber = [r for r in rows if 1 < expiry_status(r[2])[1] <= 5] green = [r for r in rows if expiry_status(r[2])[1] > 5] lines = [] lines.append(f"๐Ÿ“ฆ {len(rows)} items in pantry") lines.append(f"๐Ÿ”ด {len(red)} expiring today ๐ŸŸก {len(amber)} expiring soon ๐ŸŸข {len(green)} fine") lines.append("โ”€" * 40) if red: lines.append("\n๐Ÿ”ด COOK TODAY:") for r in red: lines.append(f" โ€ข {r[0]} (x{r[1]})") if amber: lines.append("\n๐ŸŸก USE THIS WEEK:") for r in amber: _, days = expiry_status(r[2]) lines.append(f" โ€ข {r[0]} (x{r[1]}) โ€” {days} days left") if green: lines.append("\n๐ŸŸข ALL GOOD:") for r in green: _, days = expiry_status(r[2]) lines.append(f" โ€ข {r[0]} (x{r[1]}) โ€” {days} days") return "\n".join(lines) def mark_used_local(item_name): init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("UPDATE pantry SET used=1 WHERE item_name=? AND used=0", (item_name,)) rows_affected = c.rowcount conn.commit() conn.close() return get_pantry_display() def parse_receipt_gradio(image): if image is None: return "Please upload a receipt image.", [], "" try: img_bytes = io.BytesIO() if not isinstance(image, PILImage.Image): image = PILImage.fromarray(image) image.save(img_bytes, format="JPEG") img_bytes = img_bytes.getvalue() parse_fn = modal.Function.from_name("zerowastekitchen", "parse_receipt") result = parse_fn.remote(img_bytes) nemotron_cls = modal.Cls.from_name("zerowastekitchen", "NemotronService") item_names = [i["name"] for i in result.get("items", [])] expiry_map = nemotron_cls().estimate_expiry.remote(item_names) for item in result.get("items", []): item["expiry"] = expiry_map.get( item["name"], estimate_expiry(item["name"]) ) save_to_db(result["items"], result.get("shop", "unknown"), DB_PATH) n = len(result["items"]) shop = result.get("shop", "unknown") status = f"โœ… {n} item{'s' if n != 1 else ''} saved to pantry (from {shop})" return format_parsed_items(result["items"]), result["items"], status except Exception as e: return f"Error: {str(e)}", [], "โŒ Nothing saved โ€” see error above" def generate_recipe_with_audio(character, cuisine, use_expiring, read_full_recipe): try: init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute( "SELECT item_name, quantity, estimated_expiry FROM pantry WHERE used=0 ORDER BY estimated_expiry ASC" ) rows = c.fetchall() conn.close() if not rows: return "Your pantry is empty โ€” scan a receipt first.", None all_items = [{"name": r[0], "quantity": r[1], "expiry": r[2]} for r in rows] for item in all_items: _, days = expiry_status(item["expiry"]) item["days_left"] = days if use_expiring: featured = [i for i in all_items if i["days_left"] <= 5] if not featured: featured = all_items[:1] else: # Checkbox off: feature a varied selection from the whole pantry import random featured = random.sample(all_items, min(3, len(all_items))) expiring = featured expiring_names = [i["name"] for i in featured] dialogue_fn = modal.Function.from_name("zerowastekitchen", "generate_dialogue") recipe_fn = modal.Cls.from_name("zerowastekitchen", "NemotronService")().generate_recipe if USE_VOXCPM: tts_fn = modal.Cls.from_name("zerowastekitchen", "VoxCPMService")().narrate else: tts_fn = modal.Function.from_name("zerowastekitchen", "text_to_speech") with concurrent.futures.ThreadPoolExecutor() as executor: dialogue_future = executor.submit( dialogue_fn.remote, character, expiring_names[:5], cuisine ) recipe_future = executor.submit( recipe_fn.remote, all_items, expiring, cuisine ) dialogue = dialogue_future.result() recipe = recipe_future.result() speech_text = f"{dialogue}\n{recipe}" if read_full_recipe else dialogue audio_bytes = tts_fn.remote(speech_text, character) emoji = CHAR_EMOJI.get(character, "๐Ÿง“") output = f"{emoji} {character} says:\n\"{dialogue}\"\n\n" output += recipe buf = io.BytesIO(audio_bytes) audio_np, sr = sf.read(buf) return output, (sr, audio_np) except Exception as e: return f"Error: {str(e)}", None with gr.Blocks(title="ZeroWasteKitchen", theme=gr.themes.Soft()) as app: items_state = gr.State([]) gr.Markdown("# ๐ŸŒฟ ZeroWasteKitchen\n*Scan receipts ยท track expiry ยท cook with personality*") with gr.Tabs(): with gr.Tab("1 ยท Upload receipt"): gr.Markdown("### Upload your grocery receipt") receipt_image = gr.Image( label="Receipt photo", type="pil", sources=["upload", "webcam"] ) parse_btn = gr.Button("Parse receipt", variant="primary") parsed_output = gr.Textbox( label="Parsed items", lines=10, interactive=False ) save_status = gr.Textbox( label="", interactive=False, max_lines=1 ) parse_btn.click( fn=parse_receipt_gradio, inputs=[receipt_image], outputs=[parsed_output, items_state, save_status] ) with gr.Tab("2 ยท My pantry"): gr.Markdown("### Your current pantry") refresh_btn = gr.Button("Refresh", size="sm") pantry_output = gr.Textbox( label="Pantry status", lines=15, interactive=False, value="Click Refresh to load pantry" ) with gr.Row(): mark_item = gr.Textbox( label="Item name to mark as used", placeholder="e.g. CURRIS LEAVES PACKET" ) mark_btn = gr.Button("Mark as used", size="sm") refresh_btn.click(fn=get_pantry_display, outputs=[pantry_output]) mark_btn.click( fn=mark_used_local, inputs=[mark_item], outputs=[pantry_output] ) with gr.Tab("3 ยท Cook"): gr.Markdown("### Pick your character and cuisine") with gr.Row(): character = gr.Dropdown( choices=list(CHARACTERS.keys()), value="Grandma (Ammamma)", label="Character" ) cuisine = gr.Dropdown( choices=CUISINES, value="South Indian", label="Cuisine" ) use_expiring = gr.Checkbox( label="Prioritise expiring items", value=True ) read_full_recipe = gr.Checkbox( label="๐Ÿ”Š Read the full recipe aloud (takes longer)", value=True ) cook_btn = gr.Button( "Cook with what I have", variant="primary" ) recipe_output = gr.Textbox( label="Your recipe", lines=20, interactive=False ) audio_output = gr.Audio( label="๐Ÿ”Š Hear it", type="numpy", autoplay=True ) cook_btn.click( fn=generate_recipe_with_audio, inputs=[character, cuisine, use_expiring, read_full_recipe], outputs=[recipe_output, audio_output] ) app.load(fn=get_pantry_display, outputs=[pantry_output]) if __name__ == "__main__": app.launch(server_name="0.0.0.0")