Skip to content
BACK_TO_INDEX

UNIVERSITY PROJECT // 4-PERSON TEAM @ SFU

SMARTCART

An AI-powered weekly meal planning platform built as a university group project at SFU. Uses Google Gemini to generate personalized meal plans with allergy safety verification and auto-regeneration. Features a grocery aggregation engine that deduplicates ingredients, performs unit conversions (tsp→tbsp→cup→ml), and subtracts pantry items.

JAVA SPRING BOOT GEMINI AI POSTGRESQL 72 TESTS SPRING SECURITY
Code on screen with dark programming interface

TECH_STACK

Java 17 Spring Boot 4 PostgreSQL Flyway Spring Security Google Gemini API Maven Docker Render

MY_ROLE

Led the database architecture, REST API design, and full backend infrastructure across both iterations. In Iteration 2, I owned security hardening (CSRF, Spring Security filter chain, prompt injection prevention), allergy verification with auto-regeneration, balanced meal planning prompts, the protein/veggie/fruit weekly picker, meal swap (single + multi-select), and wrote 53 of the team's 72 automated tests.

53/72
Tests Written
26/64
Story Points
13
PRs Merged

KEY_FEATURES

AI Meal Plan Generation

Gemini generates full weekly meal plans (breakfast/lunch/dinner) with structured JSON schema enforcement, retry logic for incomplete responses, and nutritional plate model guidelines.

Allergy Safety Verification

Post-generation allergen scanning of every recipe ingredient with automatic re-generation of flagged meals. Unsafe slots are replaced or removed entirely.

Grocery Aggregation Engine

Merges duplicate ingredients across all recipes, converts between units (tsp→tbsp→cup→ml), subtracts pantry quantities, and auto-categorizes items.

Ingredient Normalization

Canonicalizes names ("boneless skinless chicken thighs" → "chicken thigh", "scallions" → "green onion"), strips descriptors, singularizes plurals.

SOURCE_CODE

GeminiService.java AI GENERATION + RETRY
public GeminiMealPlanDto generateMealPlan(...) {
    // Auto-rotate: sample subset for variety
    preferredProteins = sampleFromList(preferredProteins, 3);
    preferredVegetables = sampleFromList(preferredVegetables, 5);

    Set<MealSlot> requestedSlots = expectedSlots(mealSchedule);
    LinkedHashMap<MealSlot, MealEntry> acceptedMeals = new LinkedHashMap<>();
    List<MealSlot> remainingSlots = new ArrayList<>(requestedSlots);

    // Retry loop: up to 3 attempts for incomplete plans
    for (int attempt = 1; attempt <= 3 && !remainingSlots.isEmpty(); attempt++) {
        String prompt = buildFullMealPlanPrompt(
            pantryIngredients, servingSize, dietaryRestrictions,
            allergies, preferredCuisines, remainingSlotSet, ...);

        lastRaw = generateContent(prompt);
        GeminiMealPlanDto dto = parseJson(lastRaw, GeminiMealPlanDto.class);
        ExtractedMeals extracted = extractValidMeals(dto, remainingSlotSet);

        acceptedMeals.putAll(extracted.acceptedMeals());
        remainingSlots = extracted.missingSlots();
    }
    return buildMealPlan(acceptedMeals);
}
GroceryAggregationService.java UNIT CONVERSION ENGINE
static UnitInfo from(String rawUnit) {
    String unit = normalizeUnitText(rawUnit);
    return switch (unit) {
        case "tsp", "teaspoon"     -> new UnitInfo("volume", "tsp",  4.929);
        case "tbsp", "tablespoon"  -> new UnitInfo("volume", "tbsp", 14.787);
        case "cup"               -> new UnitInfo("volume", "cup", 236.588);
        case "ml"                -> new UnitInfo("volume", "ml",  1.0);
        case "oz", "ounce"      -> new UnitInfo("weight", "oz", 28.350);
        case "lb", "pound"      -> new UnitInfo("weight", "lb", 453.592);
        case "g", "gram"        -> new UnitInfo("weight", "g",  1.0);
        default -> new UnitInfo("exact:" + unit, unit, 1.0);
    };
}

double convertTo(double quantity, UnitInfo target) {
    double baseQuantity = quantity * baseFactor;
    return baseQuantity / target.baseFactor;
}
MealPlanApiController.java ALLERGY SAFETY GATE
// Post-generation allergy verification with auto-regeneration
Set<String> allergenSet = parseAllergenSet(effectiveAllergies);

for (MealEntry entry : mealPlan.meals()) {
    if (!allergenSet.isEmpty()
        && recipeContainsAllergen(entry.recipe(), allergenSet)) {

        log.warn("Allergy detected in {} {} — re-generating",
                entry.dayOfWeek(), entry.mealType());

        // Re-generate this single slot with stronger allergy prompt
        GeminiRecipeDto replacement = geminiService
            .generateSingleMeal(entry.dayOfWeek(), entry.mealType(),
                effectiveAllergies, dietaryRestrictions, cuisines);

        if (replacement != null
            && !recipeContainsAllergen(replacement, allergenSet)) {
            Recipe recipe = persistRecipe(replacement, servingSize);
            plan.getRecipes().add(new MealPlanRecipe(plan, recipe, day, meal));
        } else {
            removedMeals.add(entry.dayOfWeek() + " " + entry.mealType());
            log.warn("Still unsafe — slot removed");
        }
    }
}

ARCHITECTURE

AI Layer

  • Gemini 2.0 Flash / 1.5 Pro
  • Structured JSON schema enforcement
  • 3-attempt retry for incomplete plans
  • Post-generation allergen scanning
  • Single-slot re-generation

Backend

  • Spring Boot 4 REST API
  • Spring Security + JDBC sessions
  • PostgreSQL + Flyway migrations
  • Unit conversion engine
  • Ingredient normalization pipeline

Data Layer

  • Pantry item management
  • Ingredient deduplication
  • Category auto-classification
  • Docker + Render deployment
BACK_TO_INDEX