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..3e34ff2 100644 --- a/smart_pantry_manager/pages/all_recipes.py +++ b/smart_pantry_manager/pages/all_recipes.py @@ -1,70 +1,87 @@ # 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:**") + # Parse ingredients safely + ing_list = parse_ingredients(row.get("Ingredients", "")) + if ing_list: + for ing in ing_list: + st.write(f"β€’ {ing}") + else: + st.write("No ingredient data available.") + st.markdown("**πŸ‘©β€πŸ³ Instructions:**") + # Show full 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..c1f9de4 100644 --- a/smart_pantry_manager/pages/recommended_recipes.py +++ b/smart_pantry_manager/pages/recommended_recipes.py @@ -1,10 +1,13 @@ -# spell-checker: disable -""" -Recipe Recommendation Page for Smart Pantry Application -""" +# recommended_recipes.py +# Optimized Recommended Recipes page with ingredient selection and diet filter +# Date: 2025-11-20 +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 +15,219 @@ 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 your selected ingredients!") # ---------- 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() + pantry_products = sorted({p.lower().strip() for p in pantry.get("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." - ) +# ---------- User ingredient selection ---------- +st.sidebar.header("Select Ingredients to Use") +selected_ingredients = st.sidebar.multiselect( + "Choose ingredients:", options=pantry_products, default=pantry_products +) + +st.sidebar.header("Select Diet Type") +selected_diet = st.sidebar.selectbox( + "Diet preference:", ["Any", "Vegan", "Vegetarian", "Non-Vegetarian"] +) # ---------- 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") - +def load_recipes() -> pd.DataFrame: + db_path = os.path.join("smart_pantry_manager", "data", "cleaned_data.sqlite") if not os.path.exists(db_path): - st.error( - "⚠️ Recipes database not found. Please create Recipe_Dataset.sqlite inside the_app/data/." + st.error("Recipes database not found! Place cleaned_data.sqlite in data/.") + 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) - + try: + df = pd.read_sql_query("SELECT * FROM all_recipes", conn) + except Exception as e: + conn.close() + st.error(f"Error loading recipes: {e}") + return pd.DataFrame( + columns=["Title", "Ingredients", "Instructions", "Diet_Type"] + ) conn.close() + for col in ["Title", "Ingredients", "Instructions", "Diet_Type"]: + if col not in df.columns: + df[col] = "" + return df[["Title", "Ingredients", "Instructions", "Diet_Type"]] - # Normalize columns - df.columns = [c.strip().lower() for c in df.columns] - rename_map = { - "title": "Recipe", - "cleaned_ingredients": "Ingredients", - "instruction": "Instructions", - "instructions": "Instructions", - } +recipes = load_recipes() +if recipes.empty: + st.warning("No recipes found in the database.") + st.stop() - df.rename( - columns={k: v for k, v in rename_map.items() if k in df.columns}, inplace=True - ) - return df[["Recipe", "Ingredients", "Instructions"]] +# ---------- Utilities ---------- +def normalize_text(s: str) -> str: + """Normalize unicode artifacts and strip.""" + 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]: + """Parse ingredients stored as list string or comma-separated.""" + if pd.isna(ingredients_str): + return [] + s = normalize_text(ingredients_str) + try: + if s.startswith("[") and s.endswith("]"): + parsed = ast.literal_eval(s) + if isinstance(parsed, (list, tuple)): + 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: + if "|" in s: + return [normalize_text(x) for x in s.split("|") if x.strip()] + if "\n" in s: + return [normalize_text(x) for x in s.split("\n") if x.strip()] + return [s] + + +def clean_ingredient_name(s: str) -> str: + """Extract core ingredient name for matching.""" + if not s: + return "" + s = s.lower() + s = re.sub(r"\([^)]*\)", "", s) + s = re.sub(r"\d+[\/\d\s]*\s*(cup|cups|tbsp|tsp|oz|lb|lbs|g|kg|ml|l)?", "", s) + s = re.sub(r"[^a-zA-Z\u00C0-\u017F\s]+", "", s) + s = re.sub(r"\s+", " ", s) + return s.strip() -# βœ… Call the function to get the recipes DataFrame -recipes = load_recipes() - +@st.cache_data +def cached_check_availability( + recipe_ingredients: str, selected_tuple: Tuple[str, ...] +) -> Tuple[float, List[str]]: + """Return match % and missing items for selected ingredients.""" + ingredients = parse_ingredients(recipe_ingredients) + if not ingredients: + return 0.0, [] -# ---------- 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()] total = len(ingredients) available_count = 0 missing_items = [] + regexes = [ + re.compile(rf"\b{re.escape(p)}\b", flags=re.IGNORECASE) for p in selected_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 ---------- -st.subheader(f"πŸ₯˜ Personalized Recipe Matches for {username}") - + core_name = clean_ingredient_name(item) + text_to_search = core_name or item + matched = any(rx.search(text_to_search) for rx in regexes) + if matched: + available_count += 1 + else: + missing_items.append(core_name) + match_percent = (available_count / total) * 100 if total else 0.0 + return round(match_percent, 1), missing_items + + +# ---------- Filters ---------- +st.subheader(f"πŸ₯˜ Personalized Recipes for {username}") + +min_match = st.slider("Minimum match %:", 0, 100, 50, 5) +max_recipes = st.number_input("Max 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", ""), - } - ) - -results_df = pd.DataFrame(results).sort_values(by="Match %", ascending=False) +total_recipes = len(recipes) +selected_tuple = tuple(selected_ingredients) + +for idx, (_, row) in enumerate(recipes.iterrows()): + progress = (idx + 1) / total_recipes + progress_bar.progress(progress) + status_text.text(f"Processing recipe {idx + 1} of {total_recipes}...") + + if selected_diet != "Any" and row["Diet_Type"].strip() != selected_diet: + continue + + ingredients_raw = row.get("Ingredients") or "" + match_percent, missing = cached_check_availability(ingredients_raw, selected_tuple) + + if match_percent >= min_match: + instr = normalize_text(row.get("Instructions") or "") + instr_preview = instr # show full instructions + diet = row.get("Diet_Type") or "Unknown" + results.append( + { + "Recipe": row.get("Title") or "Unnamed Recipe", + "Diet": diet, + "Match %": match_percent, + "Missing": ", ".join(missing[:3]) + ("..." if len(missing) > 3 else "") + if missing + else "βœ… All available", + "Instructions": instr_preview, + "Ingredients": ingredients_raw, + } + ) +progress_bar.empty() +status_text.empty() +results_df = pd.DataFrame(results) if not results_df.empty: + results_df = results_df.sort_values(by="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[["Recipe", "Diet", "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"{match_color} {row['Recipe']} - {row['Diet']}"): + 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"] or "") + with col2: + st.markdown("**πŸ§‚ Ingredients:**") + if ing_list: + for ing in ing_list[:10]: + st.write(f"β€’ {ing}") + if len(ing_list) > 10: + st.write(f"*...and {len(ing_list) - 10} more*") + else: + st.write("No ingredient data available.") + 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