diff --git a/.gitignore b/.gitignore index b19c34b..51b8de4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ __pycache__/ *.tar.gz 2025-T1/Food-Image-Classifier/scripts/data/food-101/images/ -2025-T2/Multi-Image-Classifier/scripts/VFN/Images/ \ No newline at end of file +2025-T2/Multi-Image-Classifier/scripts/VFN/Images/.Rhistory +.python-version +.Rhistory diff --git a/2025-T3/Meal Generator/Documentation & Reports/ Week 8 - Rule-Based Meal Plan.docx b/2025-T3/Meal Generator/Documentation & Reports/ Week 8 - Rule-Based Meal Plan.docx new file mode 100644 index 0000000..64ab565 Binary files /dev/null and b/2025-T3/Meal Generator/Documentation & Reports/ Week 8 - Rule-Based Meal Plan.docx differ diff --git a/2025-T3/Meal Generator/Documentation & Reports/Week 3 - AI Personalization Document.docx b/2025-T3/Meal Generator/Documentation & Reports/Week 3 - AI Personalization Document.docx new file mode 100644 index 0000000..75714aa Binary files /dev/null and b/2025-T3/Meal Generator/Documentation & Reports/Week 3 - AI Personalization Document.docx differ diff --git a/2025-T3/Meal Generator/Documentation & Reports/Week 4 - Meal Plan Logic Flowchart.docx b/2025-T3/Meal Generator/Documentation & Reports/Week 4 - Meal Plan Logic Flowchart.docx new file mode 100644 index 0000000..a8984e3 Binary files /dev/null and b/2025-T3/Meal Generator/Documentation & Reports/Week 4 - Meal Plan Logic Flowchart.docx differ diff --git a/2025-T3/Meal Generator/Documentation & Reports/Week 4 - Rule-based design .docx b/2025-T3/Meal Generator/Documentation & Reports/Week 4 - Rule-based design .docx new file mode 100644 index 0000000..0f8495d Binary files /dev/null and b/2025-T3/Meal Generator/Documentation & Reports/Week 4 - Rule-based design .docx differ diff --git a/2025-T3/Meal Generator/Documentation & Reports/Week 9 - Chatbot intent .pdf b/2025-T3/Meal Generator/Documentation & Reports/Week 9 - Chatbot intent .pdf new file mode 100644 index 0000000..5451e72 Binary files /dev/null and b/2025-T3/Meal Generator/Documentation & Reports/Week 9 - Chatbot intent .pdf differ diff --git a/2025-T3/Meal Generator/Documentation & Reports/Week 9 - Voice intent Categories.pdf b/2025-T3/Meal Generator/Documentation & Reports/Week 9 - Voice intent Categories.pdf new file mode 100644 index 0000000..73c411d Binary files /dev/null and b/2025-T3/Meal Generator/Documentation & Reports/Week 9 - Voice intent Categories.pdf differ diff --git a/2025-T3/Meal Generator/README.md b/2025-T3/Meal Generator/README.md new file mode 100644 index 0000000..0ffc769 --- /dev/null +++ b/2025-T3/Meal Generator/README.md @@ -0,0 +1,44 @@ +# NutriHelp-AI – SIT374 Capstone Team Project (A) + +## Project Overview +The team-based capstone project NutriHelp-AI was created as a component of SIT374's Capstone Team Project (A). The project's main goal is to develop an AI-assisted nutrition assistance system that offers context-aware and safety-conscious meal suggestions based on user preferences, dietary requirements, and fundamental health considerations. + +This repository provides artifacts demonstrating my individual contribution to the team effort. + +--- + +## My Role & Contributions +My contribution in this project was on AI logic and rule-based meal recommendation design. I helped build the meal generating logic, structured food data, safety considerations, and related documentation. My work ensured that meal recommendations were consistent, understandable, and in line with project requirements. + +--- + +## Key Files & Folders + +### `meal_generator.py` +Contains the main rule-based logic that generates meal recommendations. This file explains how to use conditional logic, restrictions, and context-aware decision-making in a practical system. + +### `meal_library.json` +Stores structured meal data that is used in the recommendation logic. This file explains how to use JSON to organize and manage food-related data in an understandable, reusable way. + +### `Documentation & Reports/` +Includes written documentation and reports about system logic, context-aware recommendations, and design explanations. These documents enable understanding, reflection, and handover. + +### `Testing & Result/` +Contains testing outputs and result files used to validate the behavior of the meal recommendation logic. This category contains examples of basic system testing, assessment, and verification processes. + +--- + +## Tools & Technologies +- Python (rule-based AI logic) +- JSON (structured meal and rule data) +- Git & GitHub (version control and collaboration) + +--- + +## Notes +This project was created cooperatively as part of a team. Model training, deployment, and dependency management were handled at the system level, whereas my contributions concentrated on AI logic, data structure, documentation, and testing. + +--- + +## Academic Context +This work was created for **SIT374 - Capstone Team Project (A)** and is meant for academic assessment and portfolio submission. \ No newline at end of file diff --git a/2025-T3/Meal Generator/Testing & Result/Detailed Example.docx b/2025-T3/Meal Generator/Testing & Result/Detailed Example.docx new file mode 100644 index 0000000..c61642e Binary files /dev/null and b/2025-T3/Meal Generator/Testing & Result/Detailed Example.docx differ diff --git a/2025-T3/Meal Generator/Testing & Result/Test system with 10 users .docx b/2025-T3/Meal Generator/Testing & Result/Test system with 10 users .docx new file mode 100644 index 0000000..9dbf487 Binary files /dev/null and b/2025-T3/Meal Generator/Testing & Result/Test system with 10 users .docx differ diff --git a/2025-T3/Meal Generator/meal_generator.py b/2025-T3/Meal Generator/meal_generator.py new file mode 100644 index 0000000..f1a9c16 --- /dev/null +++ b/2025-T3/Meal Generator/meal_generator.py @@ -0,0 +1,356 @@ +import json +import random +import re +import os + +ALLERGY_MAP = { + "nuts": ["contains_nuts", "contains_tree_nut", "contains_peanut"], + "peanut": ["contains_peanut"], + "tree_nut": ["contains_tree_nut"], + "dairy": ["contains_dairy", "contains_milk", "contains_cheese", "contains_yoghurt", "contains_butter", "contains_cream", "contains_whey", "contains_casein", "contains_lactose"], + "milk": ["contains_milk", "contains_dairy"], + "egg": ["contains_egg"], + "soy": ["contains_soy"], + "gluten": ["contains_gluten", "contains_wheat", "contains_barley", "contains_rye"], + "wheat": ["contains_wheat", "contains_gluten"], + "shellfish": ["contains_shellfish"], + "fish": ["contains_fish"], + "fruit": [ "contains_fruit", "contains_apple", "contains_banana", "contains_citrus", "contains_orange", "contains_lemon", "contains_lime", "contains_grapefruit", "contains_strawberry", + "contains_kiwi", "contains_peach", "contains_mango", "contains_pineapple"], + "sesame":["contains_sesame", "contains_tahini"], + "apple": ["contains_apple"], + "banana": ["contains_banana"], + "citrus": ["contains_citrus", "contains_orange", "contains_lemon", "contains_lime", "contains_grapefruit"], + "strawberry": ["contains_strawberry"], + "kiwi": ["contains_kiwi"], +} + +CONDITION_MAP = { + # Diabetes: avoid high sugar and high GI meals + "diabetes": ["high_sugar", "high_gi", "added_sugar", "refined_carb", "sugary_drink", "dessert", "fried", "high_saturated_fat", "processed_meat", "high_salt"], + # High cholesterol: avoid high fat (saturared and fried foods) + "cholesterol": ["high_fat", "fried", "high_saturated_fat"], + "high_cholesterol": ["high_fat", "fried", "high_saturated_fat"], + # Hypertension + "hypertension": ["high_salt", "very_high_salt", "processed_meat", "pickled", "soy_sauce_heavy"], + "high_blood_pressure": ["high_salt", "very_high_salt", "processed_meat", "pickled", "soy_sauce_heavy"], + # Kidney Disease + "kidney_disease": ["high_potassium", "high_phosphorus", "high_protein"], + "ckd": ["high_potassium", "high_phosphorus", "high_protein"], + # Elderly condition rules + "elderly": ["hard_food", "crunchy", "tough_meat", "very_high_salt", "fried", "very_spicy"] +} + +def detect_allergens(text: str) -> list[str]: + if not text: + return [] + + t = text.lower() + + keywords = { + "dairy": ["milk", "cheese", "yoghurt", "yogurt", "butter", "cream", "whey", "casein", "lactose"], + "egg": ["egg", "eggs", "albumen", "ovalbumin", "egg white", "egg yolk", "mayonaise", "mayonnaise"], + "soy": ["soy", "soya", "soybean", "tofu", "edamame", "lecithin (soy)", "soy lecithin"], + "gluten": ["gluten", "wheat", "barley", "rye", "malt", "flour"], + "peanut": ["peanut", "groundnut", "peanut oil", "peanut butter"], + "nuts": ["nuts", "almond", "cashew", "walnut", "hazelnut", "pistachio", "pecan", "macadamia"], + "fish": ["fish", "salmon", "tuna", "cod", "anchovy", "sardine"], + "shellfish": ["shellfish", "shrimp", "prawn", "crab", "lobster", "clam", "clams", "mussel", "mussels", "oyster", "oysters", "scallop", "scallops"], + "sesame": ["sesame", "sesame seed", "sesame oil", "tahini", "hummus"], + "apple": ["apple", "apples", "apple juice", "apple puree"], + "banana": ["banana", "bananas", "banana powder"], + "citrus": ["orange", "oranges", "lemon", "lemons", "lime", "limes", "grapefruit", "mandarin", "citrus"], + "fruit":["strawberry", "strawberries", "kiwi", "kiwi fruit", "peach", "peaches", "mango", "mangoes", "pineapple"] + } + + found = set() + for allergen, words in keywords.items(): + for w in words: + if re.search(rf"\b{re.escape(w)}\b", t): + found.add(allergen) + break + + if "citrus" in found: + found.add("fruit") + + found = [a for a in found if a in ALLERGY_MAP] + + return list(found) + +# Load meals form JSON file +def meal_library(): + base_dir = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(base_dir, "meal_library.json") + with open(path, "r", encoding="utf-8") as file: + return json.load(file) + +def normalise_list_input(value): + # None -> [] + if value is None: + return [] + # list -> same list + if isinstance(value, list): + return value + # string "a,b" -> ["a", "b"] + # string "a" -> ["a"] + if isinstance(value, str): + # split by comma if needed + parts = [v.strip() for v in value.split(",")] + return [p for p in parts if p] # remove empty strings + # any other weird type + return [] + +# Filter by allergies +def filter_allergy(meals, allergies): + + result = [] + + allergies_lowercase = [] + for a in allergies: + allergies_lowercase.append(a.lower()) + + for meal in meals: + has_allergy = False + + for allergy in allergies_lowercase: + if allergy in ALLERGY_MAP: + to_avoid = ALLERGY_MAP[allergy] + else: + to_avoid = [] + + for tag in to_avoid: + if tag in meal["tags"]: + has_allergy = True + break + + if has_allergy: + break + + if not has_allergy: + result.append(meal) + return result + +# Filter by health condition +def filter_condition(meals, conditions): + result = [] + + conditions_lowercase = [] + for c in conditions: + conditions_lowercase.append(c.lower()) + + for meal in meals: + bad = False + + for condition in conditions_lowercase: + if condition in CONDITION_MAP: + bad_tag = CONDITION_MAP[condition] + else: + bad_tag = [] + + for tag in bad_tag: + if tag in meal["tags"]: + bad = True + break + + if bad: + break + + if not bad: + result.append(meal) + + return result + +# Filter by texture requirement +def filter_texture(meals, text): + if text is None: + text = "normal" + + text = text.lower() + + # We filter only if user needs soft food + if text != "soft": + return meals + + soft_meals = [] + for meal in meals: + if "soft_food" in meal["tags"]: + soft_meals.append(meal) + + return soft_meals + +def filter_budget(meals, budget): + if budget is None: + budget = "medium" + budget = str(budget).lower() + + # preference order (fallback) + if budget == "low": + preferred_orders = [["cost_low"], ["cost_low", "cost_medium"], ["cost_low", "cost_medium", "cost_high"]] + elif budget == "medium": + preferred_orders = [["cost_medium"], ["cost_medium", "cost_low"], ["cost_medium", "cost_low", "cost_high"]] + else: + # high budget: no need to restrict + return meals + + # Try strict first, then relax + for allowed in preferred_orders: + filtered = [] + for meal in meals: + tags = meal.get("tags", []) + if any(cost_tag in tags for cost_tag in allowed): + filtered.append(meal) + if len(filtered) > 0: + return filtered + + # if no cost tags exist at all, return original + return meals + +def pick_meal_under_calories(meals, remaining_calories): + valid_meals = [m for m in meals if m.get("calories", 0) <= remaining_calories] + if not valid_meals: + return None + return random.choice(valid_meals) + + +# Build the plan +def plan(user, all_meals): + if user is None: + user = {} + + # FALLBACK LOGIC FOR LIMITED INPUT + # allergies or conditions can be missing, string, or list + raw_allergies = user.get("allergies", []) + raw_conditions = user.get("conditions", []) + + allergies = normalise_list_input(raw_allergies) + conditions = normalise_list_input(raw_conditions) + + label_text = user.get("label_text", "") + detected_allergies = detect_allergens(label_text) + + # Combine user allergies + detected allergies + allergies = allergies + detected_allergies + allergies = list(set([a.lower() for a in allergies])) + + # texture: default to "normal" if missing or empty + texture = user.get("texture") or "normal" + + budget = user.get("budget") or "medium" + + # calories_target: default to 2000 if not given + calories = user.get("calories_target") + if calories is None: + calories = 2000 + + # Apply filters step by step + filtered = filter_allergy(all_meals, allergies) + filtered = filter_condition(filtered, conditions) + filtered = filter_texture(filtered, texture) + + conditions_lower = [str(c).lower() for c in conditions] + if "elderly" in conditions_lower: + soft_meals = [meal for meal in filtered if "soft_food" in meal.get("tags", [])] + if len(soft_meals) > 0: + filtered = soft_meals + + filtered = filter_budget(filtered, budget) + + # Split meals by type + breakfasts = [] + lunches = [] + dinners = [] + snacks = [] + + for meal in filtered: + if meal["meal_type"] == "breakfast": + breakfasts.append(meal) + elif meal["meal_type"] == "lunch": + lunches.append(meal) + elif meal["meal_type"] == "dinner": + dinners.append(meal) + elif meal["meal_type"] == "snack": + snacks.append(meal) + + # Pick one meal for each main meal, and up to 2 snacks + remaining_calories = calories + + plan = { + "breakfast": None, + "lunch": None, + "dinner": None, + "snacks": [] + } + + # Pick breakfast + plan["breakfast"] = pick_meal_under_calories(breakfasts, remaining_calories) + if plan["breakfast"]: + remaining_calories -= plan["breakfast"]["calories"] + + # Pick lunch + plan["lunch"] = pick_meal_under_calories(lunches, remaining_calories) + if plan["lunch"]: + remaining_calories -= plan["lunch"]["calories"] + + # Pick dinner + plan["dinner"] = pick_meal_under_calories(dinners, remaining_calories) + if plan["dinner"]: + remaining_calories -= plan["dinner"]["calories"] + + # Dinner fallback — MUST respect remaining calories + if plan["dinner"] is None: + soft_dinners = [m for m in dinners if "soft_food" in m.get("tags", [])] + plan["dinner"] = pick_meal_under_calories(soft_dinners, remaining_calories) + + # Reuse lunch ONLY if it fits remaining calories + if plan["dinner"] is None and plan["lunch"] is not None: + if plan["lunch"]["calories"] <= remaining_calories: + plan["dinner"] = plan["lunch"] + remaining_calories -= plan["lunch"]["calories"] + + for snack in snacks: + if len(plan["snacks"]) >= 2: + break + if snack["calories"] <= remaining_calories: + plan["snacks"].append(snack) + remaining_calories -= snack["calories"] + + # Calculate total calories + total_calories = 0 + + if plan["breakfast"] is not None: + total_calories += plan["breakfast"]["calories"] + if plan["lunch"] is not None: + total_calories += plan["lunch"]["calories"] + if plan["dinner"] is not None: + total_calories += plan["dinner"]["calories"] + + for snack in plan["snacks"]: + total_calories += snack["calories"] + + plan["total_calories"] = total_calories + plan["target_calories"] = calories + + plan["allergies_used"] = allergies + plan["detected_allergies_from_label"] = detected_allergies + plan["budget_used"] = budget + + return plan + + +if __name__ == "__main__": + meals = meal_library() + + # Test 1: Dairy allergy + user_dairy_allergy = { + "name": "Test Dairy Allergy", + "label_text": "", + "allergies": [], + "conditions": [], + "budget": "medium", + "texture": "normal", + "calories_target": 1500 + } + + print("\n=== TEST 1: Dairy Allergy ===") + result1 = plan(user_dairy_allergy, meals) + print(json.dumps(result1, indent=2, ensure_ascii=False)) diff --git a/2025-T3/Meal Generator/meal_library.json b/2025-T3/Meal Generator/meal_library.json new file mode 100644 index 0000000..8b32c56 --- /dev/null +++ b/2025-T3/Meal Generator/meal_library.json @@ -0,0 +1,129 @@ +[ + { + "id": "a1", + "name": "Oats with berries and chia", + "meal_type": "breakfast", + "calories": 350, + "tags": ["low_sugar", "high_fibre", "vegetarian", "soft_food", "cost_low"] + }, + { + "id": "a2", + "name": "Scrambled eggs on toast", + "meal_type": "breakfast", + "calories": 380, + "tags": ["high_protein", "contains_egg", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a3", + "name": "Grilled chicken salad", + "meal_type": "lunch", + "calories": 450, + "tags": ["low_fat", "low_salt", "high_protein", "cost_medium"] + }, + { + "id": "a4", + "name": "Vegetable stir-fry with tofu and rice", + "meal_type": "lunch", + "calories": 500, + "tags": ["soft_food", "low_fat", "low_salt", "asian", "contains_soy", "vegetarian", "cost_low"] + }, + { + "id": "a5", + "name": "Baked salmon with steamed veggies", + "meal_type": "dinner", + "calories": 520, + "tags": ["low_sugar", "high_protein", "low_salt", "contains_fish", "cost_high"] + }, + { + "id": "a6", + "name": "Lentil soup with soft bread", + "meal_type": "dinner", + "calories": 480, + "tags": ["vegetarian", "soft_food", "low_fat", "low_salt", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a7", + "name": "Apple slices with yoghurt", + "meal_type": "snack", + "calories": 150, + "tags": ["low_fat", "contains_yoghurt", "contains_dairy", "contains_apple", "cost_low"] + }, + { + "id": "a8", + "name": "Unsalted mixed nuts", + "meal_type": "snack", + "calories": 200, + "tags": ["contains_nuts", "contains_tree_nut", "low_sugar", "cost_medium"] + }, + { + "id": "a9", + "name": "Sugary cereal with milk", + "meal_type": "breakfast", + "calories": 420, + "tags": ["high_sugar", "high_gi", "refined_carb", "contains_dairy", "contains_milk", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a10", + "name": "Wholegrain toast with avocado", + "meal_type": "breakfast", + "calories": 360, + "tags": ["low_sugar", "high_fibre", "low_salt", "vegetarian", "contains_gluten", "contains_wheat", "cost_medium"] + }, + { + "id": "a11", + "name": "Chicken burger and fries", + "meal_type": "lunch", + "calories": 800, + "tags": ["high_fat", "high_saturated_fat", "fried", "processed_meat", "high_salt", "contains_gluten", "contains_wheat", "cost_medium"] + }, + { + "id": "a12", + "name": "Brown rice with grilled fish and veggies", + "meal_type": "lunch", + "calories": 520, + "tags": ["low_sugar", "low_gi", "low_fat", "low_salt", "high_protein", "contains_fish", "cost_high"] + }, + { + "id": "a13", + "name": "Instant noodles with seasoning packet", + "meal_type": "lunch", + "calories": 550, + "tags": ["high_salt", "very_high_salt", "refined_carb", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a14", + "name": "Beef stir-fry with vegetables and soy sauce", + "meal_type": "dinner", + "calories": 650, + "tags": ["high_salt", "soy_sauce_heavy", "asian", "high_protein", "contains_soy", "cost_high"] + }, + { + "id": "a15", + "name": "Cheese pizza slice", + "meal_type": "dinner", + "calories": 780, + "tags": ["high_fat", "high_saturated_fat", "contains_dairy", "contains_cheese", "contains_gluten", "contains_wheat", "cost_medium"] + }, + { + "id": "a16", + "name": "Donut and soft drink", + "meal_type": "snack", + "calories": 600, + "tags": ["dessert", "high_sugar", "added_sugar", "refined_carb", "sugary_drink", "high_fat", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a17", + "name": "Fresh fruit salad", + "meal_type": "snack", + "calories": 180, + "tags": ["low_sugar", "low_fat", "low_salt", "vegetarian", "cost_low", + "contains_fruit", "contains_apple", "contains_strawberry", "contains_kiwi"] + }, + { + "id": "a18", + "name": "Low-fat yoghurt with nuts", + "meal_type": "snack", + "calories": 220, + "tags": ["low_fat", "contains_yoghurt", "contains_dairy", "contains_nuts", "contains_tree_nut", "cost_medium"] + } +] diff --git a/nutrihelp_ai/main.py b/nutrihelp_ai/main.py index c324b9e..4752995 100644 --- a/nutrihelp_ai/main.py +++ b/nutrihelp_ai/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.exceptions import HTTPException as StarletteHTTPException -from nutrihelp_ai.routers import medical_report_api, chatbot_api, image_api, health_plan_api, finetune_api +from nutrihelp_ai.routers import medical_report_api, chatbot_api, image_api, health_plan_api, finetune_api, meal_plan_api from nutrihelp_ai.routers import multi_image_api # NEW: Multi-image router from nutrihelp_ai.extensions import limiter @@ -57,8 +57,10 @@ async def healthz(): app.include_router(multi_image_api.router, prefix="/ai-model/image-analysis", tags=["Multi Image Classification"]) app.include_router(health_plan_api, prefix="/ai-model/medical-report/plan", tags=["Health Plan Generation"]) +app.include_router(meal_plan_api, prefix="/ai-model/meal-plan", tags=["Meal Plan Generation"]) app.include_router(finetune_api, prefix="/ai-model/chatbot-finetune", tags=["AI Assistant Fine tune"]) + # ---- Custom Error Handlers ---- @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request: Request, exc: StarletteHTTPException): diff --git a/nutrihelp_ai/routers/__init__.py b/nutrihelp_ai/routers/__init__.py index 8fbbaab..92a5eb2 100644 --- a/nutrihelp_ai/routers/__init__.py +++ b/nutrihelp_ai/routers/__init__.py @@ -4,5 +4,5 @@ from .image_api import router as image_api from .health_plan_api import router as health_plan_api from .finetune_api import router as finetune_api - -__all__ = ["medical_report_api", "chatbot_api", "image_api", "health_plan_api", "finetune_api"] +from .meal_plan_api import router as meal_plan_api +__all__ = ["medical_report_api", "chatbot_api", "image_api", "health_plan_api", "finetune_api", "meal_plan_api"] diff --git a/nutrihelp_ai/routers/meal_plan_api.py b/nutrihelp_ai/routers/meal_plan_api.py new file mode 100644 index 0000000..0260c94 --- /dev/null +++ b/nutrihelp_ai/routers/meal_plan_api.py @@ -0,0 +1,45 @@ +import logging +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import List, Optional, Any, Dict + +from nutrihelp_ai.services.meal_generator import plan as generate_plan_logic, meal_library + +logger = logging.getLogger(__name__) +router = APIRouter() + +# Load meals once +MEALS = meal_library() + +class MealPlanInput(BaseModel): + name: Optional[str] = None + label_text: Optional[str] = "" + allergies: List[str] = Field(default_factory=list) + conditions: List[str] = Field(default_factory=list) + texture: Optional[str] = "normal" + budget: Optional[str] = "medium" + calories_target: Optional[int] = 2000 + +@router.post("/generate") +async def generate_meal_plan(input_data: MealPlanInput) -> Dict[str, Any]: + try: + logger.info("Received request for meal plan") + + user_profile = input_data.model_dump() + result = generate_plan_logic(user_profile, MEALS) + + # If nothing is available after filtering + if not result.get("breakfast") and not result.get("lunch") and not result.get("dinner"): + raise HTTPException( + status_code=400, + detail="No meals available after filtering. Try relaxing allergies/conditions/texture/budget." + ) + + logger.info("Generated meal plan successfully") + return result + + except HTTPException: + raise + except Exception as e: + logger.error("Unexpected error in /meal-plan/generate: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Meal plan generation failed due to server error.") \ No newline at end of file diff --git a/nutrihelp_ai/services/meal_generator.py b/nutrihelp_ai/services/meal_generator.py new file mode 100644 index 0000000..f1a9c16 --- /dev/null +++ b/nutrihelp_ai/services/meal_generator.py @@ -0,0 +1,356 @@ +import json +import random +import re +import os + +ALLERGY_MAP = { + "nuts": ["contains_nuts", "contains_tree_nut", "contains_peanut"], + "peanut": ["contains_peanut"], + "tree_nut": ["contains_tree_nut"], + "dairy": ["contains_dairy", "contains_milk", "contains_cheese", "contains_yoghurt", "contains_butter", "contains_cream", "contains_whey", "contains_casein", "contains_lactose"], + "milk": ["contains_milk", "contains_dairy"], + "egg": ["contains_egg"], + "soy": ["contains_soy"], + "gluten": ["contains_gluten", "contains_wheat", "contains_barley", "contains_rye"], + "wheat": ["contains_wheat", "contains_gluten"], + "shellfish": ["contains_shellfish"], + "fish": ["contains_fish"], + "fruit": [ "contains_fruit", "contains_apple", "contains_banana", "contains_citrus", "contains_orange", "contains_lemon", "contains_lime", "contains_grapefruit", "contains_strawberry", + "contains_kiwi", "contains_peach", "contains_mango", "contains_pineapple"], + "sesame":["contains_sesame", "contains_tahini"], + "apple": ["contains_apple"], + "banana": ["contains_banana"], + "citrus": ["contains_citrus", "contains_orange", "contains_lemon", "contains_lime", "contains_grapefruit"], + "strawberry": ["contains_strawberry"], + "kiwi": ["contains_kiwi"], +} + +CONDITION_MAP = { + # Diabetes: avoid high sugar and high GI meals + "diabetes": ["high_sugar", "high_gi", "added_sugar", "refined_carb", "sugary_drink", "dessert", "fried", "high_saturated_fat", "processed_meat", "high_salt"], + # High cholesterol: avoid high fat (saturared and fried foods) + "cholesterol": ["high_fat", "fried", "high_saturated_fat"], + "high_cholesterol": ["high_fat", "fried", "high_saturated_fat"], + # Hypertension + "hypertension": ["high_salt", "very_high_salt", "processed_meat", "pickled", "soy_sauce_heavy"], + "high_blood_pressure": ["high_salt", "very_high_salt", "processed_meat", "pickled", "soy_sauce_heavy"], + # Kidney Disease + "kidney_disease": ["high_potassium", "high_phosphorus", "high_protein"], + "ckd": ["high_potassium", "high_phosphorus", "high_protein"], + # Elderly condition rules + "elderly": ["hard_food", "crunchy", "tough_meat", "very_high_salt", "fried", "very_spicy"] +} + +def detect_allergens(text: str) -> list[str]: + if not text: + return [] + + t = text.lower() + + keywords = { + "dairy": ["milk", "cheese", "yoghurt", "yogurt", "butter", "cream", "whey", "casein", "lactose"], + "egg": ["egg", "eggs", "albumen", "ovalbumin", "egg white", "egg yolk", "mayonaise", "mayonnaise"], + "soy": ["soy", "soya", "soybean", "tofu", "edamame", "lecithin (soy)", "soy lecithin"], + "gluten": ["gluten", "wheat", "barley", "rye", "malt", "flour"], + "peanut": ["peanut", "groundnut", "peanut oil", "peanut butter"], + "nuts": ["nuts", "almond", "cashew", "walnut", "hazelnut", "pistachio", "pecan", "macadamia"], + "fish": ["fish", "salmon", "tuna", "cod", "anchovy", "sardine"], + "shellfish": ["shellfish", "shrimp", "prawn", "crab", "lobster", "clam", "clams", "mussel", "mussels", "oyster", "oysters", "scallop", "scallops"], + "sesame": ["sesame", "sesame seed", "sesame oil", "tahini", "hummus"], + "apple": ["apple", "apples", "apple juice", "apple puree"], + "banana": ["banana", "bananas", "banana powder"], + "citrus": ["orange", "oranges", "lemon", "lemons", "lime", "limes", "grapefruit", "mandarin", "citrus"], + "fruit":["strawberry", "strawberries", "kiwi", "kiwi fruit", "peach", "peaches", "mango", "mangoes", "pineapple"] + } + + found = set() + for allergen, words in keywords.items(): + for w in words: + if re.search(rf"\b{re.escape(w)}\b", t): + found.add(allergen) + break + + if "citrus" in found: + found.add("fruit") + + found = [a for a in found if a in ALLERGY_MAP] + + return list(found) + +# Load meals form JSON file +def meal_library(): + base_dir = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(base_dir, "meal_library.json") + with open(path, "r", encoding="utf-8") as file: + return json.load(file) + +def normalise_list_input(value): + # None -> [] + if value is None: + return [] + # list -> same list + if isinstance(value, list): + return value + # string "a,b" -> ["a", "b"] + # string "a" -> ["a"] + if isinstance(value, str): + # split by comma if needed + parts = [v.strip() for v in value.split(",")] + return [p for p in parts if p] # remove empty strings + # any other weird type + return [] + +# Filter by allergies +def filter_allergy(meals, allergies): + + result = [] + + allergies_lowercase = [] + for a in allergies: + allergies_lowercase.append(a.lower()) + + for meal in meals: + has_allergy = False + + for allergy in allergies_lowercase: + if allergy in ALLERGY_MAP: + to_avoid = ALLERGY_MAP[allergy] + else: + to_avoid = [] + + for tag in to_avoid: + if tag in meal["tags"]: + has_allergy = True + break + + if has_allergy: + break + + if not has_allergy: + result.append(meal) + return result + +# Filter by health condition +def filter_condition(meals, conditions): + result = [] + + conditions_lowercase = [] + for c in conditions: + conditions_lowercase.append(c.lower()) + + for meal in meals: + bad = False + + for condition in conditions_lowercase: + if condition in CONDITION_MAP: + bad_tag = CONDITION_MAP[condition] + else: + bad_tag = [] + + for tag in bad_tag: + if tag in meal["tags"]: + bad = True + break + + if bad: + break + + if not bad: + result.append(meal) + + return result + +# Filter by texture requirement +def filter_texture(meals, text): + if text is None: + text = "normal" + + text = text.lower() + + # We filter only if user needs soft food + if text != "soft": + return meals + + soft_meals = [] + for meal in meals: + if "soft_food" in meal["tags"]: + soft_meals.append(meal) + + return soft_meals + +def filter_budget(meals, budget): + if budget is None: + budget = "medium" + budget = str(budget).lower() + + # preference order (fallback) + if budget == "low": + preferred_orders = [["cost_low"], ["cost_low", "cost_medium"], ["cost_low", "cost_medium", "cost_high"]] + elif budget == "medium": + preferred_orders = [["cost_medium"], ["cost_medium", "cost_low"], ["cost_medium", "cost_low", "cost_high"]] + else: + # high budget: no need to restrict + return meals + + # Try strict first, then relax + for allowed in preferred_orders: + filtered = [] + for meal in meals: + tags = meal.get("tags", []) + if any(cost_tag in tags for cost_tag in allowed): + filtered.append(meal) + if len(filtered) > 0: + return filtered + + # if no cost tags exist at all, return original + return meals + +def pick_meal_under_calories(meals, remaining_calories): + valid_meals = [m for m in meals if m.get("calories", 0) <= remaining_calories] + if not valid_meals: + return None + return random.choice(valid_meals) + + +# Build the plan +def plan(user, all_meals): + if user is None: + user = {} + + # FALLBACK LOGIC FOR LIMITED INPUT + # allergies or conditions can be missing, string, or list + raw_allergies = user.get("allergies", []) + raw_conditions = user.get("conditions", []) + + allergies = normalise_list_input(raw_allergies) + conditions = normalise_list_input(raw_conditions) + + label_text = user.get("label_text", "") + detected_allergies = detect_allergens(label_text) + + # Combine user allergies + detected allergies + allergies = allergies + detected_allergies + allergies = list(set([a.lower() for a in allergies])) + + # texture: default to "normal" if missing or empty + texture = user.get("texture") or "normal" + + budget = user.get("budget") or "medium" + + # calories_target: default to 2000 if not given + calories = user.get("calories_target") + if calories is None: + calories = 2000 + + # Apply filters step by step + filtered = filter_allergy(all_meals, allergies) + filtered = filter_condition(filtered, conditions) + filtered = filter_texture(filtered, texture) + + conditions_lower = [str(c).lower() for c in conditions] + if "elderly" in conditions_lower: + soft_meals = [meal for meal in filtered if "soft_food" in meal.get("tags", [])] + if len(soft_meals) > 0: + filtered = soft_meals + + filtered = filter_budget(filtered, budget) + + # Split meals by type + breakfasts = [] + lunches = [] + dinners = [] + snacks = [] + + for meal in filtered: + if meal["meal_type"] == "breakfast": + breakfasts.append(meal) + elif meal["meal_type"] == "lunch": + lunches.append(meal) + elif meal["meal_type"] == "dinner": + dinners.append(meal) + elif meal["meal_type"] == "snack": + snacks.append(meal) + + # Pick one meal for each main meal, and up to 2 snacks + remaining_calories = calories + + plan = { + "breakfast": None, + "lunch": None, + "dinner": None, + "snacks": [] + } + + # Pick breakfast + plan["breakfast"] = pick_meal_under_calories(breakfasts, remaining_calories) + if plan["breakfast"]: + remaining_calories -= plan["breakfast"]["calories"] + + # Pick lunch + plan["lunch"] = pick_meal_under_calories(lunches, remaining_calories) + if plan["lunch"]: + remaining_calories -= plan["lunch"]["calories"] + + # Pick dinner + plan["dinner"] = pick_meal_under_calories(dinners, remaining_calories) + if plan["dinner"]: + remaining_calories -= plan["dinner"]["calories"] + + # Dinner fallback — MUST respect remaining calories + if plan["dinner"] is None: + soft_dinners = [m for m in dinners if "soft_food" in m.get("tags", [])] + plan["dinner"] = pick_meal_under_calories(soft_dinners, remaining_calories) + + # Reuse lunch ONLY if it fits remaining calories + if plan["dinner"] is None and plan["lunch"] is not None: + if plan["lunch"]["calories"] <= remaining_calories: + plan["dinner"] = plan["lunch"] + remaining_calories -= plan["lunch"]["calories"] + + for snack in snacks: + if len(plan["snacks"]) >= 2: + break + if snack["calories"] <= remaining_calories: + plan["snacks"].append(snack) + remaining_calories -= snack["calories"] + + # Calculate total calories + total_calories = 0 + + if plan["breakfast"] is not None: + total_calories += plan["breakfast"]["calories"] + if plan["lunch"] is not None: + total_calories += plan["lunch"]["calories"] + if plan["dinner"] is not None: + total_calories += plan["dinner"]["calories"] + + for snack in plan["snacks"]: + total_calories += snack["calories"] + + plan["total_calories"] = total_calories + plan["target_calories"] = calories + + plan["allergies_used"] = allergies + plan["detected_allergies_from_label"] = detected_allergies + plan["budget_used"] = budget + + return plan + + +if __name__ == "__main__": + meals = meal_library() + + # Test 1: Dairy allergy + user_dairy_allergy = { + "name": "Test Dairy Allergy", + "label_text": "", + "allergies": [], + "conditions": [], + "budget": "medium", + "texture": "normal", + "calories_target": 1500 + } + + print("\n=== TEST 1: Dairy Allergy ===") + result1 = plan(user_dairy_allergy, meals) + print(json.dumps(result1, indent=2, ensure_ascii=False)) diff --git a/nutrihelp_ai/services/meal_library.json b/nutrihelp_ai/services/meal_library.json new file mode 100644 index 0000000..8b32c56 --- /dev/null +++ b/nutrihelp_ai/services/meal_library.json @@ -0,0 +1,129 @@ +[ + { + "id": "a1", + "name": "Oats with berries and chia", + "meal_type": "breakfast", + "calories": 350, + "tags": ["low_sugar", "high_fibre", "vegetarian", "soft_food", "cost_low"] + }, + { + "id": "a2", + "name": "Scrambled eggs on toast", + "meal_type": "breakfast", + "calories": 380, + "tags": ["high_protein", "contains_egg", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a3", + "name": "Grilled chicken salad", + "meal_type": "lunch", + "calories": 450, + "tags": ["low_fat", "low_salt", "high_protein", "cost_medium"] + }, + { + "id": "a4", + "name": "Vegetable stir-fry with tofu and rice", + "meal_type": "lunch", + "calories": 500, + "tags": ["soft_food", "low_fat", "low_salt", "asian", "contains_soy", "vegetarian", "cost_low"] + }, + { + "id": "a5", + "name": "Baked salmon with steamed veggies", + "meal_type": "dinner", + "calories": 520, + "tags": ["low_sugar", "high_protein", "low_salt", "contains_fish", "cost_high"] + }, + { + "id": "a6", + "name": "Lentil soup with soft bread", + "meal_type": "dinner", + "calories": 480, + "tags": ["vegetarian", "soft_food", "low_fat", "low_salt", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a7", + "name": "Apple slices with yoghurt", + "meal_type": "snack", + "calories": 150, + "tags": ["low_fat", "contains_yoghurt", "contains_dairy", "contains_apple", "cost_low"] + }, + { + "id": "a8", + "name": "Unsalted mixed nuts", + "meal_type": "snack", + "calories": 200, + "tags": ["contains_nuts", "contains_tree_nut", "low_sugar", "cost_medium"] + }, + { + "id": "a9", + "name": "Sugary cereal with milk", + "meal_type": "breakfast", + "calories": 420, + "tags": ["high_sugar", "high_gi", "refined_carb", "contains_dairy", "contains_milk", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a10", + "name": "Wholegrain toast with avocado", + "meal_type": "breakfast", + "calories": 360, + "tags": ["low_sugar", "high_fibre", "low_salt", "vegetarian", "contains_gluten", "contains_wheat", "cost_medium"] + }, + { + "id": "a11", + "name": "Chicken burger and fries", + "meal_type": "lunch", + "calories": 800, + "tags": ["high_fat", "high_saturated_fat", "fried", "processed_meat", "high_salt", "contains_gluten", "contains_wheat", "cost_medium"] + }, + { + "id": "a12", + "name": "Brown rice with grilled fish and veggies", + "meal_type": "lunch", + "calories": 520, + "tags": ["low_sugar", "low_gi", "low_fat", "low_salt", "high_protein", "contains_fish", "cost_high"] + }, + { + "id": "a13", + "name": "Instant noodles with seasoning packet", + "meal_type": "lunch", + "calories": 550, + "tags": ["high_salt", "very_high_salt", "refined_carb", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a14", + "name": "Beef stir-fry with vegetables and soy sauce", + "meal_type": "dinner", + "calories": 650, + "tags": ["high_salt", "soy_sauce_heavy", "asian", "high_protein", "contains_soy", "cost_high"] + }, + { + "id": "a15", + "name": "Cheese pizza slice", + "meal_type": "dinner", + "calories": 780, + "tags": ["high_fat", "high_saturated_fat", "contains_dairy", "contains_cheese", "contains_gluten", "contains_wheat", "cost_medium"] + }, + { + "id": "a16", + "name": "Donut and soft drink", + "meal_type": "snack", + "calories": 600, + "tags": ["dessert", "high_sugar", "added_sugar", "refined_carb", "sugary_drink", "high_fat", "contains_gluten", "contains_wheat", "cost_low"] + }, + { + "id": "a17", + "name": "Fresh fruit salad", + "meal_type": "snack", + "calories": 180, + "tags": ["low_sugar", "low_fat", "low_salt", "vegetarian", "cost_low", + "contains_fruit", "contains_apple", "contains_strawberry", "contains_kiwi"] + }, + { + "id": "a18", + "name": "Low-fat yoghurt with nuts", + "meal_type": "snack", + "calories": 220, + "tags": ["low_fat", "contains_yoghurt", "contains_dairy", "contains_nuts", "contains_tree_nut", "cost_medium"] + } +] diff --git a/nutrihelp_ai/services/multi_image_classifier/scripts/training/predict.py b/nutrihelp_ai/services/multi_image_classifier/scripts/training/predict.py index b088727..fb655e2 100644 --- a/nutrihelp_ai/services/multi_image_classifier/scripts/training/predict.py +++ b/nutrihelp_ai/services/multi_image_classifier/scripts/training/predict.py @@ -53,7 +53,8 @@ def __init__( else: self.thresholds = torch.full((self.num_classes,), default_threshold) - ckpt = torch.load(str(self.model_file), map_location="cpu") + print("DEBUG: loading multi-image model from:", self.model_file) + ckpt = torch.load(str(self.model_file), map_location="cpu", weights_only=False) model_name = ckpt.get("model_name", model_name_fallback) self.model = MultiLabelBackbone(model_name, self.num_classes, pretrained=False) state = ckpt.get("model", ckpt)