diff --git a/smart_pantry_manager/data/clean_data b/smart_pantry_manager/data/clean_data new file mode 100644 index 0000000..4ee19f2 --- /dev/null +++ b/smart_pantry_manager/data/clean_data @@ -0,0 +1,128 @@ +# spell-checker: disable +""" +Clean recipe CSV and create SQLite DB with diet type. +""" + +import re +import sqlite3 + +import pandas as pd + +# Load CSV +df = pd.read_csv("smart_pantry_manager/data/Recipe_Dataset.csv") + +# Forbidden (haram) ingredients +haram_keywords = [ + "pork", + "ham", + "bacon", + "prosciutto", + "pancetta", + "sausage", + "wine", + "beer", + "bourbon", + "rum", + "whisky", + "vodka", + "tequila", + "cognac", + "brandy", + "liqueur", + "alcohol", + "champagne", + "sake", + "sherry", + "gin", +] + +# Meat ingredients +meat_keywords = [ + "chicken", + "beef", + "lamb", + "turkey", + "fish", + "shrimp", + "salmon", + "tuna", + "meat", + "steak", + "duck", + "anchovy", + "crab", + "lobster", + "clam", + "oyster", + "scallop", + "mussel", + "squid", + "sausage", +] + +# Animal products (vegetarian) +animal_product_keywords = [ + "egg", + "milk", + "cheese", + "butter", + "cream", + "yogurt", + "ghee", + "honey", + "mayonnaise", + "whey", + "casein", + "gelatin", +] + + +# Check haram +def contains_haram(ingredient_text): + if pd.isna(ingredient_text): + return False + text = ingredient_text.lower() + for kw in haram_keywords: + if re.search(rf"\b{kw}\b", text): + return True + return False + + +# Classify diet type +def classify_diet_type(ingredient_text): + if pd.isna(ingredient_text): + return "Unknown" + text = ingredient_text.lower() + for kw in meat_keywords: + if re.search(rf"\b{kw}\b", text): + return "Non-Vegetarian" + for kw in animal_product_keywords: + if re.search(rf"\b{kw}\b", text): + return "Vegetarian" + return "Vegan" + + +# Filter haram +df["contains_haram"] = df["Ingredients"].apply(contains_haram) +df_clean = df[~df["contains_haram"]].copy() +df_clean["Diet_Type"] = df_clean["Ingredients"].apply(classify_diet_type) +df_clean.drop("contains_haram", axis=1, inplace=True) + +# SQLite DB +conn = sqlite3.connect("smart_pantry_manager/data/cleaned_data.sqlite") + +# Tables for each diet type +for diet in ["Vegan", "Vegetarian", "Non-Vegetarian"]: + diet_df = df_clean[df_clean["Diet_Type"] == diet].copy() + diet_df.to_sql( + diet.lower().replace("-", "_") + "_recipes", + conn, + if_exists="replace", + index=False, + ) + +# Combined table +df_clean.to_sql("all_recipes", conn, if_exists="replace", index=False) + +conn.close() +print("βœ… SQLite DB created with all_recipes and diet tables.") diff --git a/smart_pantry_manager/data/cleaned_data.sqlite b/smart_pantry_manager/data/cleaned_data.sqlite new file mode 100644 index 0000000..ddf691b Binary files /dev/null and b/smart_pantry_manager/data/cleaned_data.sqlite differ diff --git a/smart_pantry_manager/data/pantry_omnia.xlsx b/smart_pantry_manager/data/pantry_omnia.xlsx new file mode 100644 index 0000000..c5b3b23 Binary files /dev/null and b/smart_pantry_manager/data/pantry_omnia.xlsx differ diff --git a/smart_pantry_manager/pages/all_recipes.py b/smart_pantry_manager/pages/all_recipes.py index 2e08ede..353a9ff 100644 --- a/smart_pantry_manager/pages/all_recipes.py +++ b/smart_pantry_manager/pages/all_recipes.py @@ -1,70 +1,84 @@ # spell-checker: disable """ -All Recipes Page for Smart Pantry Application (SQLite version) +All Recipes Page +Shows all recipes and diet type from cleaned_data.sqlite """ +import ast +import os import sqlite3 + import pandas as pd import streamlit as st st.set_page_config(page_title="All Recipes", page_icon="πŸ“œ", layout="wide") - st.title("πŸ“œ All Recipes") -st.caption("Browse all available recipes in the Smart Pantry system.") +st.caption("Browse all recipes with diet type from Smart Pantry DB.") + +DB_PATH = os.path.join("smart_pantry_manager", "data", "cleaned_data.sqlite") -# ---------- Load recipes from SQLite ---------- +# ---------- Load Recipes ---------- @st.cache_data def load_recipes(): - """ - Load recipes from the SQLite database and normalize column names. - Returns a DataFrame with columns: Recipe, Ingredients, Instructions - """ - db_path = "the_app/data/Recipe_Dataset.sqlite" - - # Connect to SQLite database - conn = sqlite3.connect(db_path) - - # Load the table "recipes" - df = pd.read_sql_query("SELECT * FROM recipes", conn) - + if not os.path.exists(DB_PATH): + st.error("❌ Recipes database not found.") + return pd.DataFrame( + columns=["Title", "Ingredients", "Instructions", "Diet_Type"] + ) + conn = sqlite3.connect(DB_PATH) + try: + df = pd.read_sql("SELECT * FROM all_recipes", conn) + except Exception as e: + st.error(f"Error loading recipes: {e}") + conn.close() + return pd.DataFrame( + columns=["Title", "Ingredients", "Instructions", "Diet_Type"] + ) conn.close() - - # Normalize column names - df.columns = [c.strip().lower() for c in df.columns] - - # Rename columns if they exist - rename_map = { - "title": "Recipe", - "cleaned_ingredients": "Ingredients", - "instruction": "Instructions", - "instructions": "Instructions", - } - df.rename( - columns={k: v for k, v in rename_map.items() if k in df.columns}, inplace=True - ) - - # Keep only required columns - required_cols = ["Recipe", "Ingredients", "Instructions"] - df = df[[col for col in required_cols if col in df.columns]] - + # Ensure required columns exist + for col in ["Title", "Ingredients", "Instructions", "Diet_Type"]: + if col not in df.columns: + df[col] = "" return df recipes = load_recipes() - -# ---------- Display recipes ---------- if recipes.empty: st.info("No recipes found.") -else: - search = st.text_input("πŸ” Search for a recipe:") - filtered = ( - recipes[recipes["Recipe"].str.contains(search, case=False, na=False)] - if search - else recipes - ) - - for _, row in filtered.iterrows(): - with st.expander(row["Recipe"]): - st.markdown(f"**πŸ§‚ Ingredients:** {row['Ingredients']}") - st.markdown(f"**πŸ‘©β€πŸ³ Instructions:** {row['Instructions']}") + st.stop() + + +# ---------- Parse Ingredients ---------- +def parse_ingredients(ingredients_str): + if pd.isna(ingredients_str): + return [] + try: + if ingredients_str.startswith("[") and ingredients_str.endswith("]"): + parsed = ast.literal_eval(ingredients_str) + return [str(x).strip() for x in parsed if str(x).strip()] + elif "," in ingredients_str: + return [x.strip() for x in ingredients_str.split(",") if x.strip()] + else: + return [ingredients_str] + except Exception: + return [ingredients_str] + + +# ---------- Display Recipes ---------- +for _, row in recipes.iterrows(): + title = str(row.get("Title", "Unnamed Recipe")) + diet = str(row.get("Diet_Type", "Unknown")) + with st.expander(f"πŸ“– {title} β€” {diet}"): + col1, col2 = st.columns([1, 2]) + with col1: + st.markdown(f"**Diet Type:** {diet}") + with col2: + st.markdown("**πŸ§‚ Ingredients:**") + ing_list = parse_ingredients(row.get("Ingredients", "")) + for ing in ing_list[:10]: + st.write(f"β€’ {ing}") + if len(ing_list) > 10: + st.write(f"*...and {len(ing_list) - 10} more*") + st.markdown("**πŸ‘©β€πŸ³ Instructions:**") + st.write(str(row.get("Instructions", "No instructions available."))) diff --git a/smart_pantry_manager/pages/recommended_recipes.py b/smart_pantry_manager/pages/recommended_recipes.py index f405106..a4bea32 100644 --- a/smart_pantry_manager/pages/recommended_recipes.py +++ b/smart_pantry_manager/pages/recommended_recipes.py @@ -1,10 +1,15 @@ # spell-checker: disable """ -Recipe Recommendation Page for Smart Pantry Application +Recommended Recipes Page +Shows recipes matched with user's pantry, including diet type """ +import ast import os +import re import sqlite3 +import unicodedata +from typing import List, Tuple import pandas as pd import streamlit as st @@ -12,159 +17,204 @@ st.set_page_config(page_title="Recommended Recipes", page_icon="🍳", layout="wide") st.title("🍳 Recommended Recipes") -st.caption("Discover recipes you can cook with what’s already in your pantry!") +st.caption("Discover recipes you can cook with what's already in your pantry!") -# ---------- Check username ---------- +# ---------- Check Username ---------- if "username" not in st.session_state or not st.session_state["username"]: - st.warning("Please go to the Home page and enter your username first.") + st.warning("Please go to Home page and enter your username first.") st.stop() username = st.session_state["username"] -USER_FILE = f"the_app/data/pantry_{username.replace(' ', '_').lower()}.xlsx" +USER_FILE = os.path.join( + "smart_pantry_manager", "data", f"pantry_{username.replace(' ', '_').lower()}.xlsx" +) -# ---------- Load pantry ---------- try: pantry = pd.read_excel(USER_FILE) - pantry["Product"] = pantry["Product"].astype(str).str.lower() + if "Product" in pantry.columns: + pantry["Product"] = pantry["Product"].astype(str).str.lower().str.strip() + else: + pantry["Product"] = "" except FileNotFoundError: - st.info("Your pantry is empty. Please add items on the Home page.") + st.info("Your pantry is empty. Add items on Home page first.") st.stop() -# ---------- Auto remove expired items ---------- -if "Expiry Date" in pantry.columns: - before = len(pantry) - pantry = pantry[ - pd.to_datetime(pantry["Expiry Date"], errors="coerce") >= pd.Timestamp.today() - ] - if len(pantry) < before: - st.warning( - f"⏰ Removed {before - len(pantry)} expired item(s) from your pantry automatically." - ) +pantry_products = sorted({p for p in pantry["Product"].tolist() if p and p.strip()}) +pantry_regexes = [ + re.compile(rf"\b{re.escape(p)}\b", flags=re.IGNORECASE) for p in pantry_products +] + +# ---------- Load Recipes ---------- +DB_PATH = os.path.join("smart_pantry_manager", "data", "cleaned_data.sqlite") -# ---------- Load recipes ---------- @st.cache_data def load_recipes(): - """Load recipes from the SQLite database and clean up column names.""" - db_path = os.path.join("the_app", "data", "Recipe_Dataset.sqlite") - - if not os.path.exists(db_path): - st.error( - "⚠️ Recipes database not found. Please create Recipe_Dataset.sqlite inside the_app/data/." + if not os.path.exists(DB_PATH): + st.error("Recipes database not found.") + return pd.DataFrame( + columns=["Title", "Ingredients", "Instructions", "Diet_Type"] + ) + conn = sqlite3.connect(DB_PATH) + try: + df = pd.read_sql("SELECT * FROM all_recipes", conn) + except Exception as e: + st.error(f"Error reading recipes: {e}") + conn.close() + return pd.DataFrame( + columns=["Title", "Ingredients", "Instructions", "Diet_Type"] ) - return pd.DataFrame(columns=["Recipe", "Ingredients", "Instructions"]) - - # Connect to SQLite database - conn = sqlite3.connect(db_path) - - # Read the 'recipes' table - df = pd.read_sql_query("SELECT * FROM recipes", conn) - conn.close() + for col in ["Title", "Ingredients", "Instructions", "Diet_Type"]: + if col not in df.columns: + df[col] = "" + return df - # Normalize columns - df.columns = [c.strip().lower() for c in df.columns] - - rename_map = { - "title": "Recipe", - "cleaned_ingredients": "Ingredients", - "instruction": "Instructions", - "instructions": "Instructions", - } - - df.rename( - columns={k: v for k, v in rename_map.items() if k in df.columns}, inplace=True - ) - return df[["Recipe", "Ingredients", "Instructions"]] +recipes = load_recipes() +if recipes.empty: + st.info("No recipes available in DB.") + st.stop() -# βœ… Call the function to get the recipes DataFrame -recipes = load_recipes() +# ---------- Utilities ---------- +def normalize_text(s: str) -> str: + if s is None: + return "" + s = str(s) + s = unicodedata.normalize("NFKC", s) + s = re.sub(r"[\u200b-\u200f\u2028\u2029]", "", s) + return s.strip() + + +def parse_ingredients(ingredients_str: str) -> List[str]: + if not ingredients_str: + return [] + s = normalize_text(str(ingredients_str)) + try: + if s.startswith("[") and s.endswith("]"): + parsed = ast.literal_eval(s) + return [normalize_text(str(x)) for x in parsed if str(x).strip()] + if "," in s: + return [normalize_text(x) for x in s.split(",") if x.strip()] + return [s] + except Exception: + return [s] + + +def strip_leading_qty(s: str) -> str: + if not s: + return "" + s = s.lower() + s = re.sub(r"^\s*\(?\d+(?:[\/\u00BC-\u00BE\u2150-\u215E]?\d*)?\)?\s*", "", s) + s = re.sub( + r"^\s*\d+(\.\d+)?\s*(cup|cups|tbsp|tbsp.|tbsps|tsp|tsp.|oz|lb|lbs|g|kg|ml|l)\b", + "", + s, + ) + s = re.sub(r"^\s*(?:one|two|three|four|a|an)\s+", "", s) + s = re.sub(r"^[\-\–\β€”\s]+", "", s) + return s.strip() -# ---------- Helper for unit conversion ---------- -def convert_units(amount, from_unit, to_unit): - """Convert between basic units (g↔kg, ml↔l).""" - conversions = { - ("g", "kg"): 1 / 1000, - ("kg", "g"): 1000, - ("ml", "l"): 1 / 1000, - ("l", "ml"): 1000, - } - if from_unit == to_unit: - return amount - return amount * conversions.get((from_unit, to_unit), 1) - - -# ---------- Recipe matching ---------- -def check_recipe_availability(recipe_ingredients, pantry_data): - """Check how many ingredients from a recipe are available in the pantry.""" - ingredients = [x.strip() for x in str(recipe_ingredients).split(",") if x.strip()] +@st.cache_data +def cached_check_availability( + recipe_ingredients: str, pantry_products_tuple: Tuple[str, ...] +): + ingredients = parse_ingredients(recipe_ingredients) + if not ingredients: + return 0.0, [] total = len(ingredients) available_count = 0 missing_items = [] - + regexes = [ + re.compile(rf"\b{re.escape(p)}\b", re.IGNORECASE) for p in pantry_products_tuple + ] for item in ingredients: - try: - name_qty = item.split(":") - name = name_qty[0].strip().lower() - qty_unit = name_qty[1].strip() if len(name_qty) > 1 else "" - qty = float("".join(filter(str.isdigit, qty_unit)) or 0) - unit = "".join(filter(str.isalpha, qty_unit)) or "count" - except (ValueError, IndexError, TypeError): - name = item.lower().strip() - qty = 0 - unit = "count" - match = pantry_data[pantry_data["Product"] == name] - if match.empty: - missing_items.append(name) - continue - - try: - pantry_qty = float(match.iloc[0].get("Quantity", 0)) - pantry_unit = match.iloc[0].get("Unit", "count") - - converted_needed = convert_units(qty, unit, pantry_unit) - if pantry_qty >= converted_needed: - available_count += 1 - else: - missing_items.append(name) - - except (ValueError, TypeError): - # Catch invalid numeric or conversion issues - missing_items.append(name) - - match_percentage = (available_count / total) * 100 if total > 0 else 0 - return match_percentage, missing_items - - -# ---------- Display results ---------- + item_norm = normalize_text(str(item)).lower() + name_candidate = strip_leading_qty(item_norm) + text_to_search = name_candidate or item_norm + matched = any(rx.search(text_to_search) for rx in regexes) + if matched: + available_count += 1 + else: + words = text_to_search.split() + short = " ".join(words[-3:] if len(words) > 3 else words) + missing_items.append(short) + match_percentage = (available_count / total) * 100 if total > 0 else 0.0 + return round(match_percentage, 1), missing_items + + +# ---------- Filters ---------- st.subheader(f"πŸ₯˜ Personalized Recipe Matches for {username}") +col1, col2 = st.columns(2) +with col1: + min_match = st.slider("Minimum match percentage:", 0, 100, 50, 5) +with col2: + max_recipes = st.number_input("Maximum recipes to show:", 10, 200, 20, 5) + +st.write("πŸ” Analyzing recipes...") +progress_bar = st.progress(0) +status_text = st.empty() results = [] -for _, row in recipes.iterrows(): - match_percent, missing = check_recipe_availability(str(row["Ingredients"]), pantry) - results.append( - { - "Recipe": row.get("Recipe", "Unnamed Recipe"), - "Match %": round(match_percent, 1), - "Missing": ", ".join(missing) if missing else "All available", - "Instructions": row.get("Instructions", ""), - } - ) +total_recipes = len(recipes) +pantry_key = tuple(pantry_products) + +for idx, (_, row) in enumerate(recipes.iterrows()): + progress_bar.progress((idx + 1) / total_recipes) + status_text.text(f"Processing recipe {idx + 1} of {total_recipes}...") + ingredients_raw = normalize_text(str(row.get("Ingredients", ""))) + match_percent, missing = cached_check_availability(ingredients_raw, pantry_key) + if match_percent >= min_match: + instr = normalize_text(str(row.get("Instructions", ""))) + instr_preview = instr[:500] + "..." if len(instr) > 500 else instr + results.append( + { + "Title": row.get("Title", "Unnamed Recipe"), + "Diet_Type": row.get("Diet_Type", "Unknown"), + "Match %": match_percent, + "Missing": ", ".join(missing[:3]) + ("..." if len(missing) > 3 else "") + if missing + else "βœ… All available", + "Instructions": instr_preview, + "Ingredients": ingredients_raw, + } + ) -results_df = pd.DataFrame(results).sort_values(by="Match %", ascending=False) +progress_bar.empty() +status_text.empty() +results_df = pd.DataFrame(results) if not results_df.empty: + results_df = results_df.sort_values("Match %", ascending=False).head( + int(max_recipes) + ) + st.success(f"βœ… Found {len(results_df)} matching recipes!") st.write("### πŸ“‹ Recipe Match Overview") - st.dataframe(results_df[["Recipe", "Match %", "Missing"]], use_container_width=True) - + st.dataframe( + results_df[["Title", "Diet_Type", "Match %", "Missing"]].reset_index(drop=True), + use_container_width=True, + hide_index=True, + ) st.write("### πŸ“– Recipe Details") for _, row in results_df.iterrows(): - with st.expander(f"{row['Recipe']} β€” {row['Match %']}% match"): - st.markdown(f"**βœ… Available ingredients:** {row['Match %']}% match") - st.markdown(f"**❌ Missing ingredients:** {row['Missing']}") - st.markdown(f"**πŸ‘©β€πŸ³ Instructions:** {row['Instructions']}") + match_color = ( + "🟒" if row["Match %"] >= 80 else "🟑" if row["Match %"] >= 60 else "🟠" + ) + with st.expander(f"{row['Title']} β€” {row['Diet_Type']} {match_color}"): + col1, col2 = st.columns([1, 2]) + with col1: + st.markdown(f"**Match:** {row['Match %']}%") + st.markdown(f"**Missing:** {row['Missing']}") + ing_list = parse_ingredients(row["Ingredients"]) + with col2: + st.markdown("**πŸ§‚ Ingredients:**") + for ing in ing_list[:10]: + st.write(f"β€’ {ing}") + if len(ing_list) > 10: + st.write(f"*...and {len(ing_list) - 10} more*") + st.markdown("**πŸ‘©β€πŸ³ Instructions:**") + st.write(row["Instructions"] or "No instructions available.") else: - st.info("No recipes could be matched with your pantry.") + st.info(f"No recipes found with at least {min_match}% match.") diff --git a/smart_pantry_manager/smart_pantry.py b/smart_pantry_manager/smart_pantry.py index 30d923f..21ffdf6 100644 --- a/smart_pantry_manager/smart_pantry.py +++ b/smart_pantry_manager/smart_pantry.py @@ -4,11 +4,11 @@ Smart Pantry Web App (Home Page Only) Features: -- Each user has a personal pantry (saved to Excel) +- Personal pantry for each user (saved to Excel) - Add, edit, and track products with expiry alerts -- Quantity + Unit input (supports numeric + count) +- Quantity + unit input (numeric or count) - Small loading animation when saving -- Optional intro demo video +- Optional intro/demo video Date: 29/10/2025 """ @@ -23,7 +23,6 @@ # ---------- Page Setup ---------- st.set_page_config(page_title="Smart Pantry Manager", page_icon="🧺", layout="centered") - st.title("πŸ“± Smart Pantry Manager πŸ“Š") st.subheader("Track your pantry items and discover what you can cook πŸ‘‡") @@ -35,24 +34,23 @@ st.warning("Please enter your name to start using the app.") st.stop() -# Store username globally for use in other pages st.session_state["username"] = username # Create user-specific file path -os.makedirs("the_app/data", exist_ok=True) -USER_FILE = f"the_app/data/pantry_{username.replace(' ', '_').lower()}.xlsx" +os.makedirs("smart_pantry_manager/data", exist_ok=True) +USER_FILE = os.path.join( + "smart_pantry_manager", "data", f"pantry_{username.replace(' ', '_').lower()}.xlsx" +) # ---------- Load Pantry ---------- @st.cache_data def load_pantry(file_path): - """Load pantry data for a specific user or create an empty table.""" + """Load pantry data or create empty DataFrame.""" try: df = pd.read_excel(file_path) df["Expiry Date"] = pd.to_datetime(df["Expiry Date"], errors="coerce") df["Days Left"] = (df["Expiry Date"] - datetime.now()).dt.days - - # 🧹 Auto-remove expired items df = df[df["Days Left"] >= 0].reset_index(drop=True) return df except FileNotFoundError: @@ -72,7 +70,6 @@ def load_pantry(file_path): # ---------- Add New Product ---------- st.header("βž• Add a New Product") - product = st.text_input("Product name:") category = st.selectbox( "Category:", @@ -102,7 +99,7 @@ def load_pantry(file_path): if st.button("πŸ’Ύ Save product"): if product: with st.spinner("πŸ’Ύ Saving product... please wait..."): - time.sleep(2) # simulate loading + time.sleep(1) today = datetime.now().date() days_left = (expiry - today).days new_row = { @@ -131,11 +128,9 @@ def load_pantry(file_path): if not data.empty: expired = data[data["Days Left"] < 0] expiring_soon = data[(data["Days Left"] >= 0) & (data["Days Left"] <= 3)] - if not expired.empty: st.error("❌ Some products have expired:") st.table(expired[["Product", "Expiry Date", "Days Left"]]) - if not expiring_soon.empty: st.warning("⏰ Some products are expiring soon:") st.table(expiring_soon[["Product", "Expiry Date", "Days Left"]]) @@ -145,7 +140,6 @@ def load_pantry(file_path): def color_days(val): - """Color based on days left.""" if val < 0: color = "#ff4d4d" elif val <= 3: @@ -156,9 +150,9 @@ def color_days(val): if not data.empty: - styled_data = data.reset_index(drop=True).style.applymap( - color_days, subset=["Days Left"] - ) + # Sort by Days Left ascending + data_sorted = data.sort_values("Days Left").reset_index(drop=True) + styled_data = data_sorted.style.applymap(color_days, subset=["Days Left"]) st.dataframe(styled_data, use_container_width=True) else: st.info("Your pantry is empty. Add your first product above!") diff --git a/the_app/data/pantry_omnia.xlsx b/the_app/data/pantry_omnia.xlsx new file mode 100644 index 0000000..d5664c2 Binary files /dev/null and b/the_app/data/pantry_omnia.xlsx differ