Commit ·
8ce8f4c
1
Parent(s): 8e03a78
feat: persistent pantry on HF Spaces /data, TTS wired, clean pipeline
Browse files- app.py +104 -48
- modal_services.py +44 -58
- utils.py +7 -10
app.py
CHANGED
|
@@ -11,15 +11,18 @@ from PIL import Image as PILImage
|
|
| 11 |
|
| 12 |
sys.path.append(os.path.dirname(__file__))
|
| 13 |
from utils import (
|
| 14 |
-
estimate_expiry, format_parsed_items,
|
| 15 |
-
|
| 16 |
)
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
CHARACTERS = {
|
| 19 |
-
"Grandma (Ammamma)": "
|
| 20 |
-
"Chef": "
|
| 21 |
-
"Fitness Coach": "
|
| 22 |
-
"Food Critic": "
|
| 23 |
}
|
| 24 |
|
| 25 |
CUISINES = ["South Indian", "North Indian", "Italian", "Mediterranean", "East Asian", "Surprise me"]
|
|
@@ -32,6 +35,56 @@ CHAR_EMOJI = {
|
|
| 32 |
}
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def parse_receipt_gradio(image):
|
| 36 |
if image is None:
|
| 37 |
return "Please upload a receipt image.", []
|
|
@@ -55,7 +108,7 @@ def parse_receipt_gradio(image):
|
|
| 55 |
estimate_expiry(item["name"])
|
| 56 |
)
|
| 57 |
|
| 58 |
-
save_to_db(result["items"], result.get("shop", "unknown"))
|
| 59 |
|
| 60 |
return format_parsed_items(result["items"]), result["items"]
|
| 61 |
|
|
@@ -64,55 +117,58 @@ def parse_receipt_gradio(image):
|
|
| 64 |
|
| 65 |
|
| 66 |
def generate_recipe_with_audio(character, cuisine, use_expiring):
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
|
| 80 |
-
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
output += recipe
|
| 111 |
|
| 112 |
-
|
| 113 |
-
audio_np, sr = sf.read(buf)
|
| 114 |
|
| 115 |
-
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
with gr.Blocks(title="ZeroWasteKitchen", theme=gr.themes.Soft()) as app:
|
|
@@ -153,7 +209,7 @@ with gr.Blocks(title="ZeroWasteKitchen", theme=gr.themes.Soft()) as app:
|
|
| 153 |
label="Pantry status",
|
| 154 |
lines=15,
|
| 155 |
interactive=False,
|
| 156 |
-
value=
|
| 157 |
)
|
| 158 |
with gr.Row():
|
| 159 |
mark_item = gr.Textbox(
|
|
@@ -161,9 +217,9 @@ with gr.Blocks(title="ZeroWasteKitchen", theme=gr.themes.Soft()) as app:
|
|
| 161 |
placeholder="e.g. CURRIS LEAVES PACKET"
|
| 162 |
)
|
| 163 |
mark_btn = gr.Button("Mark as used", size="sm")
|
| 164 |
-
refresh_btn.click(fn=
|
| 165 |
mark_btn.click(
|
| 166 |
-
fn=
|
| 167 |
inputs=[mark_item],
|
| 168 |
outputs=[pantry_output]
|
| 169 |
)
|
|
|
|
| 11 |
|
| 12 |
sys.path.append(os.path.dirname(__file__))
|
| 13 |
from utils import (
|
| 14 |
+
estimate_expiry, format_parsed_items,
|
| 15 |
+
save_to_db, expiry_status, init_db
|
| 16 |
)
|
| 17 |
|
| 18 |
+
# Use HF Spaces persistent storage if available
|
| 19 |
+
DB_PATH = "/data/pantry.db" if os.path.exists("/data") else "db/pantry.db"
|
| 20 |
+
|
| 21 |
CHARACTERS = {
|
| 22 |
+
"Grandma (Ammamma)": "Warm loving grandma who hates food waste.",
|
| 23 |
+
"Chef": "Sharp professional chef. Direct and brilliant.",
|
| 24 |
+
"Fitness Coach": "Enthusiastic fitness coach obsessed with gains.",
|
| 25 |
+
"Food Critic": "Pompous but secretly warm food critic.",
|
| 26 |
}
|
| 27 |
|
| 28 |
CUISINES = ["South Indian", "North Indian", "Italian", "Mediterranean", "East Asian", "Surprise me"]
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
|
| 38 |
+
def get_pantry_display():
|
| 39 |
+
init_db(DB_PATH)
|
| 40 |
+
conn = sqlite3.connect(DB_PATH)
|
| 41 |
+
c = conn.cursor()
|
| 42 |
+
c.execute("SELECT item_name, quantity, estimated_expiry FROM pantry WHERE used=0 ORDER BY estimated_expiry ASC")
|
| 43 |
+
rows = c.fetchall()
|
| 44 |
+
conn.close()
|
| 45 |
+
|
| 46 |
+
if not rows:
|
| 47 |
+
return "Your pantry is empty — scan a receipt to get started."
|
| 48 |
+
|
| 49 |
+
red = [r for r in rows if expiry_status(r[2])[1] <= 1]
|
| 50 |
+
amber = [r for r in rows if 1 < expiry_status(r[2])[1] <= 5]
|
| 51 |
+
green = [r for r in rows if expiry_status(r[2])[1] > 5]
|
| 52 |
+
|
| 53 |
+
lines = []
|
| 54 |
+
lines.append(f"📦 {len(rows)} items in pantry")
|
| 55 |
+
lines.append(f"🔴 {len(red)} expiring today 🟡 {len(amber)} expiring soon 🟢 {len(green)} fine")
|
| 56 |
+
lines.append("─" * 40)
|
| 57 |
+
|
| 58 |
+
if red:
|
| 59 |
+
lines.append("\n🔴 COOK TODAY:")
|
| 60 |
+
for r in red:
|
| 61 |
+
lines.append(f" • {r[0]} (x{r[1]})")
|
| 62 |
+
|
| 63 |
+
if amber:
|
| 64 |
+
lines.append("\n🟡 USE THIS WEEK:")
|
| 65 |
+
for r in amber:
|
| 66 |
+
_, days = expiry_status(r[2])
|
| 67 |
+
lines.append(f" • {r[0]} (x{r[1]}) — {days} days left")
|
| 68 |
+
|
| 69 |
+
if green:
|
| 70 |
+
lines.append("\n🟢 ALL GOOD:")
|
| 71 |
+
for r in green:
|
| 72 |
+
_, days = expiry_status(r[2])
|
| 73 |
+
lines.append(f" • {r[0]} (x{r[1]}) — {days} days")
|
| 74 |
+
|
| 75 |
+
return "\n".join(lines)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def mark_used_local(item_name):
|
| 79 |
+
init_db(DB_PATH)
|
| 80 |
+
conn = sqlite3.connect(DB_PATH)
|
| 81 |
+
c = conn.cursor()
|
| 82 |
+
c.execute("UPDATE pantry SET used=1 WHERE item_name=? AND used=0", (item_name,))
|
| 83 |
+
conn.commit()
|
| 84 |
+
conn.close()
|
| 85 |
+
return get_pantry_display()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
def parse_receipt_gradio(image):
|
| 89 |
if image is None:
|
| 90 |
return "Please upload a receipt image.", []
|
|
|
|
| 108 |
estimate_expiry(item["name"])
|
| 109 |
)
|
| 110 |
|
| 111 |
+
save_to_db(result["items"], result.get("shop", "unknown"), DB_PATH)
|
| 112 |
|
| 113 |
return format_parsed_items(result["items"]), result["items"]
|
| 114 |
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
def generate_recipe_with_audio(character, cuisine, use_expiring):
|
| 120 |
+
try:
|
| 121 |
+
init_db(DB_PATH)
|
| 122 |
+
conn = sqlite3.connect(DB_PATH)
|
| 123 |
+
c = conn.cursor()
|
| 124 |
+
c.execute(
|
| 125 |
+
"SELECT item_name, quantity, estimated_expiry FROM pantry WHERE used=0 ORDER BY estimated_expiry ASC"
|
| 126 |
+
)
|
| 127 |
+
rows = c.fetchall()
|
| 128 |
+
conn.close()
|
| 129 |
|
| 130 |
+
if not rows:
|
| 131 |
+
return "Your pantry is empty — scan a receipt first.", None
|
| 132 |
|
| 133 |
+
all_items = [{"name": r[0], "quantity": r[1], "expiry": r[2]} for r in rows]
|
| 134 |
|
| 135 |
+
for item in all_items:
|
| 136 |
+
_, days = expiry_status(item["expiry"])
|
| 137 |
+
item["days_left"] = days
|
| 138 |
|
| 139 |
+
expiring = [i for i in all_items if i["days_left"] <= 5]
|
| 140 |
+
expiring_names = [i["name"] for i in expiring]
|
| 141 |
|
| 142 |
+
if not expiring_names:
|
| 143 |
+
expiring_names = [all_items[0]["name"]] if all_items else []
|
| 144 |
|
| 145 |
+
dialogue_fn = modal.Function.from_name("zerowastekitchen", "generate_dialogue")
|
| 146 |
+
recipe_fn = modal.Function.from_name("zerowastekitchen", "generate_recipe_llm")
|
| 147 |
+
tts_fn = modal.Function.from_name("zerowastekitchen", "text_to_speech")
|
| 148 |
|
| 149 |
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
| 150 |
+
dialogue_future = executor.submit(
|
| 151 |
+
dialogue_fn.remote, character, expiring_names[:5], cuisine
|
| 152 |
+
)
|
| 153 |
+
recipe_future = executor.submit(
|
| 154 |
+
recipe_fn.remote, all_items, expiring, cuisine
|
| 155 |
+
)
|
| 156 |
+
dialogue = dialogue_future.result()
|
| 157 |
+
recipe = recipe_future.result()
|
| 158 |
+
|
| 159 |
+
audio_bytes = tts_fn.remote(dialogue, character)
|
| 160 |
|
| 161 |
+
emoji = CHAR_EMOJI.get(character, "🧓")
|
| 162 |
+
output = f"{emoji} {character} says:\n\"{dialogue}\"\n\n"
|
| 163 |
+
output += recipe
|
| 164 |
|
| 165 |
+
buf = io.BytesIO(audio_bytes)
|
| 166 |
+
audio_np, sr = sf.read(buf)
|
|
|
|
| 167 |
|
| 168 |
+
return output, (sr, audio_np)
|
|
|
|
| 169 |
|
| 170 |
+
except Exception as e:
|
| 171 |
+
return f"Error: {str(e)}", None
|
| 172 |
|
| 173 |
|
| 174 |
with gr.Blocks(title="ZeroWasteKitchen", theme=gr.themes.Soft()) as app:
|
|
|
|
| 209 |
label="Pantry status",
|
| 210 |
lines=15,
|
| 211 |
interactive=False,
|
| 212 |
+
value=get_pantry_display()
|
| 213 |
)
|
| 214 |
with gr.Row():
|
| 215 |
mark_item = gr.Textbox(
|
|
|
|
| 217 |
placeholder="e.g. CURRIS LEAVES PACKET"
|
| 218 |
)
|
| 219 |
mark_btn = gr.Button("Mark as used", size="sm")
|
| 220 |
+
refresh_btn.click(fn=get_pantry_display, outputs=[pantry_output])
|
| 221 |
mark_btn.click(
|
| 222 |
+
fn=mark_used_local,
|
| 223 |
inputs=[mark_item],
|
| 224 |
outputs=[pantry_output]
|
| 225 |
)
|
modal_services.py
CHANGED
|
@@ -2,37 +2,24 @@ import modal
|
|
| 2 |
from modal import Volume
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
-
# App definition
|
| 6 |
app = modal.App("zerowastekitchen")
|
| 7 |
|
| 8 |
-
# Volume for model storage
|
| 9 |
model_volume = Volume.from_name("model-cache", create_if_missing=True)
|
| 10 |
MODEL_DIR = Path("/models")
|
| 11 |
|
| 12 |
-
# Download image - lightweight, just needs huggingface_hub
|
| 13 |
download_image = (
|
| 14 |
modal.Image.debian_slim()
|
| 15 |
.pip_install("huggingface_hub", "hf_transfer")
|
| 16 |
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
|
| 17 |
)
|
| 18 |
|
| 19 |
-
# Base image for OCR, expiry, dialogue, recipe
|
| 20 |
base_image = (
|
| 21 |
modal.Image.debian_slim()
|
| 22 |
.pip_install(
|
| 23 |
-
"torch",
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"pillow",
|
| 28 |
-
"einops",
|
| 29 |
-
"sentencepiece",
|
| 30 |
-
"timm",
|
| 31 |
-
"open_clip_torch",
|
| 32 |
-
"av",
|
| 33 |
-
"numpy",
|
| 34 |
-
"huggingface_hub",
|
| 35 |
-
"hf_transfer"
|
| 36 |
)
|
| 37 |
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
|
| 38 |
)
|
|
@@ -51,7 +38,7 @@ tts_image = (
|
|
| 51 |
)
|
| 52 |
)
|
| 53 |
|
| 54 |
-
|
| 55 |
@app.function(
|
| 56 |
image=download_image,
|
| 57 |
volumes={str(MODEL_DIR): model_volume},
|
|
@@ -69,33 +56,22 @@ def download_models():
|
|
| 69 |
local_dir=str(MODEL_DIR / "minicpm-v"),
|
| 70 |
token=token
|
| 71 |
)
|
| 72 |
-
|
| 73 |
print("Downloading tiny-aya-fire...")
|
| 74 |
snapshot_download(
|
| 75 |
"CohereLabs/tiny-aya-fire",
|
| 76 |
local_dir=str(MODEL_DIR / "tiny-aya-fire"),
|
| 77 |
token=token
|
| 78 |
)
|
| 79 |
-
|
| 80 |
print("Downloading Nemotron 4B...")
|
| 81 |
snapshot_download(
|
| 82 |
"nvidia/Nemotron-Mini-4B-Instruct",
|
| 83 |
local_dir=str(MODEL_DIR / "nemotron-4b"),
|
| 84 |
token=token
|
| 85 |
)
|
| 86 |
-
|
| 87 |
-
print("Downloading Magpie TTS...")
|
| 88 |
-
snapshot_download(
|
| 89 |
-
"nvidia/magpie_tts_multilingual_357m",
|
| 90 |
-
local_dir=str(MODEL_DIR / "magpie-tts"),
|
| 91 |
-
token=token
|
| 92 |
-
)
|
| 93 |
-
|
| 94 |
model_volume.commit()
|
| 95 |
print("All models downloaded.")
|
| 96 |
|
| 97 |
|
| 98 |
-
# ── 1. RECEIPT OCR ──────────────────────────────────────────
|
| 99 |
@app.function(
|
| 100 |
gpu="T4",
|
| 101 |
image=base_image,
|
|
@@ -175,7 +151,6 @@ Return as JSON only, no other text:
|
|
| 175 |
return {"shop": "unknown", "items": [], "raw": str(e)}
|
| 176 |
|
| 177 |
|
| 178 |
-
# ── 2. EXPIRY ESTIMATION ─────────────────────────────────────
|
| 179 |
@app.function(
|
| 180 |
gpu="T4",
|
| 181 |
image=base_image,
|
|
@@ -231,7 +206,6 @@ Reply with a single integer only. No explanation.
|
|
| 231 |
return expiry_map
|
| 232 |
|
| 233 |
|
| 234 |
-
# ── 3. CHARACTER DIALOGUE ────────────────────────────────────
|
| 235 |
@app.function(
|
| 236 |
gpu="T4",
|
| 237 |
image=base_image,
|
|
@@ -242,6 +216,7 @@ Reply with a single integer only. No explanation.
|
|
| 242 |
)
|
| 243 |
def generate_dialogue(character: str, expiring_items: list, cuisine: str) -> str:
|
| 244 |
import torch
|
|
|
|
| 245 |
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 246 |
|
| 247 |
tokenizer = AutoTokenizer.from_pretrained(str(MODEL_DIR / "tiny-aya-fire"))
|
|
@@ -252,11 +227,13 @@ def generate_dialogue(character: str, expiring_items: list, cuisine: str) -> str
|
|
| 252 |
)
|
| 253 |
|
| 254 |
character_prompts = {
|
| 255 |
-
"Grandma (Ammamma)": """You are Ammamma, a warm
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
"
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
|
| 262 |
system = character_prompts.get(character, character_prompts["Grandma (Ammamma)"])
|
|
@@ -267,7 +244,7 @@ def generate_dialogue(character: str, expiring_items: list, cuisine: str) -> str
|
|
| 267 |
<|user|>
|
| 268 |
These grocery items are expiring soon: {items_str}
|
| 269 |
We are cooking {cuisine} food today.
|
| 270 |
-
Give your in-character reaction
|
| 271 |
<|assistant|>
|
| 272 |
"""
|
| 273 |
|
|
@@ -287,15 +264,25 @@ Give your in-character reaction about the expiring food. 2-3 sentences maximum.
|
|
| 287 |
skip_special_tokens=True
|
| 288 |
).strip()
|
| 289 |
|
| 290 |
-
# Clean up leaked
|
| 291 |
response = response.split("<|system|>")[0].strip()
|
| 292 |
response = response.split("system|>")[0].strip()
|
|
|
|
|
|
|
| 293 |
response = response.split("<|")[0].strip()
|
| 294 |
response = response.split("##")[0].strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
return response
|
| 296 |
|
| 297 |
|
| 298 |
-
# ── 4. RECIPE GENERATION ─────────────────────────────────────
|
| 299 |
@app.function(
|
| 300 |
gpu="T4",
|
| 301 |
image=base_image,
|
|
@@ -343,7 +330,7 @@ STEPS:
|
|
| 343 |
3. ...
|
| 344 |
4. ...
|
| 345 |
5. ...
|
| 346 |
-
|
| 347 |
<|assistant|>
|
| 348 |
"""
|
| 349 |
|
|
@@ -363,7 +350,6 @@ IMPORTANT: Maximum 5 steps. Stop here. Do not write more than 5 steps.
|
|
| 363 |
skip_special_tokens=True
|
| 364 |
).strip()
|
| 365 |
|
| 366 |
-
response = response.split("<|")[0].strip()
|
| 367 |
response = response.split("##")[0].strip()
|
| 368 |
|
| 369 |
# Force truncate to 5 steps
|
|
@@ -377,11 +363,10 @@ IMPORTANT: Maximum 5 steps. Stop here. Do not write more than 5 steps.
|
|
| 377 |
if step_count >= 5:
|
| 378 |
break
|
| 379 |
|
| 380 |
-
response = "\n".join(truncated)
|
| 381 |
return response
|
| 382 |
|
| 383 |
|
| 384 |
-
# ── 5. TEXT TO SPEECH ────────────────────────────────────────
|
| 385 |
@app.function(
|
| 386 |
gpu="T4",
|
| 387 |
image=tts_image,
|
|
@@ -393,13 +378,13 @@ IMPORTANT: Maximum 5 steps. Stop here. Do not write more than 5 steps.
|
|
| 393 |
def text_to_speech(text: str, character: str) -> bytes:
|
| 394 |
import torch
|
| 395 |
import io
|
| 396 |
-
import soundfile as sf
|
| 397 |
import os
|
|
|
|
| 398 |
|
| 399 |
-
print("Downloading XTTS-v2...")
|
| 400 |
os.environ["COQUI_TOS_AGREED"] = "1"
|
| 401 |
from TTS.api import TTS
|
| 402 |
-
|
|
|
|
| 403 |
|
| 404 |
speaker_map = {
|
| 405 |
"Grandma (Ammamma)": "Claribel Dervla",
|
|
@@ -409,14 +394,21 @@ def text_to_speech(text: str, character: str) -> bytes:
|
|
| 409 |
}
|
| 410 |
speaker = speaker_map.get(character, "Claribel Dervla")
|
| 411 |
|
| 412 |
-
#
|
| 413 |
sentences = text.replace("!", ".").replace("?", ".").split(".")
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
| 416 |
wav = tts.tts(
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
)
|
| 421 |
|
| 422 |
buf = io.BytesIO()
|
|
@@ -426,16 +418,12 @@ def text_to_speech(text: str, character: str) -> bytes:
|
|
| 426 |
|
| 427 |
@app.local_entrypoint()
|
| 428 |
def main():
|
| 429 |
-
print("Downloading all models to volume...")
|
| 430 |
-
download_models.remote()
|
| 431 |
-
print("Done.")
|
| 432 |
-
# Test expiry estimation
|
| 433 |
test_items = ["INDIA WALA PANEER", "CURRIS LEAVES PACKET", "BASMATI RICE"]
|
|
|
|
| 434 |
print("Testing expiry estimation...")
|
| 435 |
expiry_map = estimate_expiry_llm.remote(test_items)
|
| 436 |
print(expiry_map)
|
| 437 |
|
| 438 |
-
# Test dialogue
|
| 439 |
print("\nTesting Ammamma dialogue...")
|
| 440 |
dialogue = generate_dialogue.remote(
|
| 441 |
"Grandma (Ammamma)",
|
|
@@ -444,14 +432,12 @@ def main():
|
|
| 444 |
)
|
| 445 |
print(dialogue)
|
| 446 |
|
| 447 |
-
# Test recipe
|
| 448 |
print("\nTesting recipe generation...")
|
| 449 |
all_items = [{"name": i} for i in test_items]
|
| 450 |
expiring = [{"name": i} for i in test_items[:2]]
|
| 451 |
recipe = generate_recipe_llm.remote(all_items, expiring, "South Indian")
|
| 452 |
print(recipe)
|
| 453 |
|
| 454 |
-
# Test TTS
|
| 455 |
print("\nTesting TTS...")
|
| 456 |
audio_bytes = text_to_speech.remote(dialogue, "Grandma (Ammamma)")
|
| 457 |
with open("test_audio.wav", "wb") as f:
|
|
|
|
| 2 |
from modal import Volume
|
| 3 |
from pathlib import Path
|
| 4 |
|
|
|
|
| 5 |
app = modal.App("zerowastekitchen")
|
| 6 |
|
|
|
|
| 7 |
model_volume = Volume.from_name("model-cache", create_if_missing=True)
|
| 8 |
MODEL_DIR = Path("/models")
|
| 9 |
|
|
|
|
| 10 |
download_image = (
|
| 11 |
modal.Image.debian_slim()
|
| 12 |
.pip_install("huggingface_hub", "hf_transfer")
|
| 13 |
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
|
| 14 |
)
|
| 15 |
|
|
|
|
| 16 |
base_image = (
|
| 17 |
modal.Image.debian_slim()
|
| 18 |
.pip_install(
|
| 19 |
+
"torch", "torchvision", "transformers>=5.7.0",
|
| 20 |
+
"accelerate", "pillow", "einops", "sentencepiece",
|
| 21 |
+
"timm", "open_clip_torch", "av", "numpy",
|
| 22 |
+
"huggingface_hub", "hf_transfer"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
|
| 25 |
)
|
|
|
|
| 38 |
)
|
| 39 |
)
|
| 40 |
|
| 41 |
+
|
| 42 |
@app.function(
|
| 43 |
image=download_image,
|
| 44 |
volumes={str(MODEL_DIR): model_volume},
|
|
|
|
| 56 |
local_dir=str(MODEL_DIR / "minicpm-v"),
|
| 57 |
token=token
|
| 58 |
)
|
|
|
|
| 59 |
print("Downloading tiny-aya-fire...")
|
| 60 |
snapshot_download(
|
| 61 |
"CohereLabs/tiny-aya-fire",
|
| 62 |
local_dir=str(MODEL_DIR / "tiny-aya-fire"),
|
| 63 |
token=token
|
| 64 |
)
|
|
|
|
| 65 |
print("Downloading Nemotron 4B...")
|
| 66 |
snapshot_download(
|
| 67 |
"nvidia/Nemotron-Mini-4B-Instruct",
|
| 68 |
local_dir=str(MODEL_DIR / "nemotron-4b"),
|
| 69 |
token=token
|
| 70 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
model_volume.commit()
|
| 72 |
print("All models downloaded.")
|
| 73 |
|
| 74 |
|
|
|
|
| 75 |
@app.function(
|
| 76 |
gpu="T4",
|
| 77 |
image=base_image,
|
|
|
|
| 151 |
return {"shop": "unknown", "items": [], "raw": str(e)}
|
| 152 |
|
| 153 |
|
|
|
|
| 154 |
@app.function(
|
| 155 |
gpu="T4",
|
| 156 |
image=base_image,
|
|
|
|
| 206 |
return expiry_map
|
| 207 |
|
| 208 |
|
|
|
|
| 209 |
@app.function(
|
| 210 |
gpu="T4",
|
| 211 |
image=base_image,
|
|
|
|
| 216 |
)
|
| 217 |
def generate_dialogue(character: str, expiring_items: list, cuisine: str) -> str:
|
| 218 |
import torch
|
| 219 |
+
import re
|
| 220 |
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 221 |
|
| 222 |
tokenizer = AutoTokenizer.from_pretrained(str(MODEL_DIR / "tiny-aya-fire"))
|
|
|
|
| 227 |
)
|
| 228 |
|
| 229 |
character_prompts = {
|
| 230 |
+
"Grandma (Ammamma)": """You are Ammamma, a warm loving grandma who hates food waste.
|
| 231 |
+
Speak warmly in English. Mention the specific expiring items by name.
|
| 232 |
+
Express urgency about using them before they go bad.
|
| 233 |
+
Show love and excitement about cooking. 2-3 sentences only. Be specific and concise.""",
|
| 234 |
+
"Chef": "You are a sharp professional chef. Be direct and impatient but brilliant. Mention the expiring items. 2-3 sentences only.",
|
| 235 |
+
"Fitness Coach": "You are an enthusiastic fitness coach obsessed with gains and clean eating. Mention the expiring items. 2-3 sentences only.",
|
| 236 |
+
"Food Critic": "You are a pompous food critic who is secretly warm. Be dramatic about the expiring items. 2-3 sentences only.",
|
| 237 |
}
|
| 238 |
|
| 239 |
system = character_prompts.get(character, character_prompts["Grandma (Ammamma)"])
|
|
|
|
| 244 |
<|user|>
|
| 245 |
These grocery items are expiring soon: {items_str}
|
| 246 |
We are cooking {cuisine} food today.
|
| 247 |
+
Give your in-character reaction. 2-3 sentences maximum.
|
| 248 |
<|assistant|>
|
| 249 |
"""
|
| 250 |
|
|
|
|
| 264 |
skip_special_tokens=True
|
| 265 |
).strip()
|
| 266 |
|
| 267 |
+
# Clean up leaked content
|
| 268 |
response = response.split("<|system|>")[0].strip()
|
| 269 |
response = response.split("system|>")[0].strip()
|
| 270 |
+
response = response.split("<|user|>")[0].strip()
|
| 271 |
+
response = response.split("user|>")[0].strip()
|
| 272 |
response = response.split("<|")[0].strip()
|
| 273 |
response = response.split("##")[0].strip()
|
| 274 |
+
response = response.split("Can you")[0].strip()
|
| 275 |
+
response = response.split("Please")[0].strip()
|
| 276 |
+
response = response.split("Ammamma,")[0].strip()
|
| 277 |
+
response = re.sub(r'#\w*', '', response).strip()
|
| 278 |
+
|
| 279 |
+
# Limit to 3 sentences
|
| 280 |
+
sentences = [s.strip() for s in response.split(".") if s.strip()]
|
| 281 |
+
response = ". ".join(sentences[:3]) + "."
|
| 282 |
+
|
| 283 |
return response
|
| 284 |
|
| 285 |
|
|
|
|
| 286 |
@app.function(
|
| 287 |
gpu="T4",
|
| 288 |
image=base_image,
|
|
|
|
| 330 |
3. ...
|
| 331 |
4. ...
|
| 332 |
5. ...
|
| 333 |
+
Stop here. Do not write more than 5 steps.
|
| 334 |
<|assistant|>
|
| 335 |
"""
|
| 336 |
|
|
|
|
| 350 |
skip_special_tokens=True
|
| 351 |
).strip()
|
| 352 |
|
|
|
|
| 353 |
response = response.split("##")[0].strip()
|
| 354 |
|
| 355 |
# Force truncate to 5 steps
|
|
|
|
| 363 |
if step_count >= 5:
|
| 364 |
break
|
| 365 |
|
| 366 |
+
response = "\n".join(truncated)
|
| 367 |
return response
|
| 368 |
|
| 369 |
|
|
|
|
| 370 |
@app.function(
|
| 371 |
gpu="T4",
|
| 372 |
image=tts_image,
|
|
|
|
| 378 |
def text_to_speech(text: str, character: str) -> bytes:
|
| 379 |
import torch
|
| 380 |
import io
|
|
|
|
| 381 |
import os
|
| 382 |
+
import soundfile as sf
|
| 383 |
|
|
|
|
| 384 |
os.environ["COQUI_TOS_AGREED"] = "1"
|
| 385 |
from TTS.api import TTS
|
| 386 |
+
|
| 387 |
+
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2")
|
| 388 |
|
| 389 |
speaker_map = {
|
| 390 |
"Grandma (Ammamma)": "Claribel Dervla",
|
|
|
|
| 394 |
}
|
| 395 |
speaker = speaker_map.get(character, "Claribel Dervla")
|
| 396 |
|
| 397 |
+
# Deduplicate and limit sentences
|
| 398 |
sentences = text.replace("!", ".").replace("?", ".").split(".")
|
| 399 |
+
seen = []
|
| 400 |
+
for s in sentences:
|
| 401 |
+
s = s.strip()
|
| 402 |
+
if s and s not in seen:
|
| 403 |
+
seen.append(s)
|
| 404 |
+
if len(seen) >= 3:
|
| 405 |
+
break
|
| 406 |
+
short_text = ". ".join(seen) + "."
|
| 407 |
|
| 408 |
wav = tts.tts(
|
| 409 |
+
text=short_text,
|
| 410 |
+
speaker=speaker,
|
| 411 |
+
language="en"
|
| 412 |
)
|
| 413 |
|
| 414 |
buf = io.BytesIO()
|
|
|
|
| 418 |
|
| 419 |
@app.local_entrypoint()
|
| 420 |
def main():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
test_items = ["INDIA WALA PANEER", "CURRIS LEAVES PACKET", "BASMATI RICE"]
|
| 422 |
+
|
| 423 |
print("Testing expiry estimation...")
|
| 424 |
expiry_map = estimate_expiry_llm.remote(test_items)
|
| 425 |
print(expiry_map)
|
| 426 |
|
|
|
|
| 427 |
print("\nTesting Ammamma dialogue...")
|
| 428 |
dialogue = generate_dialogue.remote(
|
| 429 |
"Grandma (Ammamma)",
|
|
|
|
| 432 |
)
|
| 433 |
print(dialogue)
|
| 434 |
|
|
|
|
| 435 |
print("\nTesting recipe generation...")
|
| 436 |
all_items = [{"name": i} for i in test_items]
|
| 437 |
expiring = [{"name": i} for i in test_items[:2]]
|
| 438 |
recipe = generate_recipe_llm.remote(all_items, expiring, "South Indian")
|
| 439 |
print(recipe)
|
| 440 |
|
|
|
|
| 441 |
print("\nTesting TTS...")
|
| 442 |
audio_bytes = text_to_speech.remote(dialogue, "Grandma (Ammamma)")
|
| 443 |
with open("test_audio.wav", "wb") as f:
|
utils.py
CHANGED
|
@@ -51,9 +51,9 @@ def format_parsed_items(items):
|
|
| 51 |
lines.append(f"{emoji} {item['name']} (qty: {item.get('quantity','1')}) — {label}")
|
| 52 |
return "\n".join(lines)
|
| 53 |
|
| 54 |
-
def init_db():
|
| 55 |
-
os.makedirs(os.path.dirname(
|
| 56 |
-
conn = sqlite3.connect(
|
| 57 |
c = conn.cursor()
|
| 58 |
c.execute('''CREATE TABLE IF NOT EXISTS pantry (
|
| 59 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -68,13 +68,11 @@ def init_db():
|
|
| 68 |
conn.commit()
|
| 69 |
conn.close()
|
| 70 |
|
| 71 |
-
def save_to_db(items: list, shop: str):
|
| 72 |
-
init_db()
|
| 73 |
-
conn = sqlite3.connect(
|
| 74 |
c = conn.cursor()
|
| 75 |
for item in items:
|
| 76 |
-
# If item exists, update quantity and expiry
|
| 77 |
-
# If not, insert new row
|
| 78 |
c.execute('''
|
| 79 |
INSERT INTO pantry (item_name, quantity, price, purchase_date, estimated_expiry, shop)
|
| 80 |
VALUES (?, ?, ?, ?, ?, ?)
|
|
@@ -82,8 +80,7 @@ def save_to_db(items: list, shop: str):
|
|
| 82 |
quantity = quantity || '+' || excluded.quantity,
|
| 83 |
estimated_expiry = excluded.estimated_expiry,
|
| 84 |
purchase_date = excluded.purchase_date
|
| 85 |
-
''',
|
| 86 |
-
(
|
| 87 |
item["name"],
|
| 88 |
item.get("quantity", "1"),
|
| 89 |
item.get("price", ""),
|
|
|
|
| 51 |
lines.append(f"{emoji} {item['name']} (qty: {item.get('quantity','1')}) — {label}")
|
| 52 |
return "\n".join(lines)
|
| 53 |
|
| 54 |
+
def init_db(db_path=DB_PATH):
|
| 55 |
+
os.makedirs(os.path.dirname(db_path) if os.path.dirname(db_path) else ".", exist_ok=True)
|
| 56 |
+
conn = sqlite3.connect(db_path)
|
| 57 |
c = conn.cursor()
|
| 58 |
c.execute('''CREATE TABLE IF NOT EXISTS pantry (
|
| 59 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 68 |
conn.commit()
|
| 69 |
conn.close()
|
| 70 |
|
| 71 |
+
def save_to_db(items: list, shop: str, db_path=DB_PATH):
|
| 72 |
+
init_db(db_path)
|
| 73 |
+
conn = sqlite3.connect(db_path)
|
| 74 |
c = conn.cursor()
|
| 75 |
for item in items:
|
|
|
|
|
|
|
| 76 |
c.execute('''
|
| 77 |
INSERT INTO pantry (item_name, quantity, price, purchase_date, estimated_expiry, shop)
|
| 78 |
VALUES (?, ?, ?, ?, ?, ?)
|
|
|
|
| 80 |
quantity = quantity || '+' || excluded.quantity,
|
| 81 |
estimated_expiry = excluded.estimated_expiry,
|
| 82 |
purchase_date = excluded.purchase_date
|
| 83 |
+
''', (
|
|
|
|
| 84 |
item["name"],
|
| 85 |
item.get("quantity", "1"),
|
| 86 |
item.get("price", ""),
|