diff --git a/backend/routes/recommendations.js b/backend/routes/recommendations.js new file mode 100644 index 0000000..1fb51f0 --- /dev/null +++ b/backend/routes/recommendations.js @@ -0,0 +1,113 @@ +const express = require('express'); +const router = express.Router(); +const { GoogleGenerativeAI } = require('@google/generative-ai'); +const Meal = require('../models/Meal'); +const logger = require('../utils/logger'); +const APIKeyManager = require('../utils/apiKeyManager'); +const { recommendationPrompt } = require('../utils/prompts'); + +const apiKeyManager = new APIKeyManager(); + +const DAILY_TARGETS = { + calories: 2000, + protein: 50, + carbs: 250, + fat: 65, +}; + +router.get('/recommendations', async (req, res) => { + const requestId = Date.now().toString(); + + try { + logger.info(`Recommendation request started: ${requestId}`); + + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const todaysMeals = await Meal.find({ + createdAt: { $gte: startOfDay }, + }).lean(); + + const dailyTotals = todaysMeals.reduce( + (acc, meal) => { + acc.calories += meal.calories || 0; + acc.protein += meal.macronutrients?.protein || 0; + acc.carbs += meal.macronutrients?.carbs || 0; + acc.fat += meal.macronutrients?.fat || 0; + return acc; + }, + { calories: 0, protein: 0, carbs: 0, fat: 0 } + ); + + const remaining = { + calories: Math.max(0, DAILY_TARGETS.calories - dailyTotals.calories), + protein: Math.max(0, DAILY_TARGETS.protein - dailyTotals.protein), + carbs: Math.max(0, DAILY_TARGETS.carbs - dailyTotals.carbs), + fat: Math.max(0, DAILY_TARGETS.fat - dailyTotals.fat), + }; + + const recentFoods = todaysMeals + .map(meal => meal.foodName) + .filter(Boolean); + + const prompt = recommendationPrompt( + remaining.calories, + remaining.protein, + remaining.carbs, + remaining.fat, + recentFoods + ); + + const apiKey = await apiKeyManager.getValidKey(); + const genAI = new GoogleGenerativeAI(apiKey); + const model = genAI.getGenerativeModel({ model: 'gemini-flash-latest' }); + + const result = await model.generateContent([prompt]); + const response = await result.response; + let text = response.text(); + + text = text + .replace(/```json/g, '') + .replace(/```/g, '') + .trim(); + + let recipes; + try { + recipes = JSON.parse(text); + logger.info(`Recommendations generated successfully: ${requestId}`); + } catch (parseError) { + logger.error(`Failed to parse recommendation response: ${requestId}`, parseError); + return res.status(500).json({ + error: 'Failed to parse AI recommendations', + requestId, + }); + } + + if (!Array.isArray(recipes)) { + recipes = [recipes]; + } + + res.json({ + recipes, + dailyTotals, + remaining, + mealsLogged: todaysMeals.length, + }); + } catch (error) { + if (error.message === 'All API keys have exceeded rate limits') { + logger.error(`API rate limit exceeded: ${requestId}`, error); + return res.status(429).json({ + error: 'Service temporarily unavailable. Please try again later.', + requestId, + }); + } + + logger.error(`Recommendation generation failed: ${requestId}`, error); + res.status(500).json({ + error: 'Failed to generate recommendations', + requestId, + }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 8a18c51..6619fe0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,24 +3,17 @@ require('dotenv').config(); const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser'); - -const app = express(); -app.use(cookieParser()); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const multer = require('multer'); const path = require('path'); -const chatRoute = require('./routes/chat.route'); const fs = require('fs'); -const chatRoute = require('./routes/chat.route'); - -const fs = require('fs'); - const logger = require('./utils/logger'); - const authRoutes = require('./routes/authRoutes'); +const chatRoute = require('./routes/chat.route'); + +const app = express(); -// Validate required environment variables const requiredEnvVars = ['GEMINI_API_KEY', 'MONGO_URI', 'JWT_SECRET']; for (const envVar of requiredEnvVars) { if (!process.env[envVar]) { @@ -31,25 +24,21 @@ for (const envVar of requiredEnvVars) { const PORT = process.env.PORT || 5000; -// Security middleware app.use(helmet()); -// Rate limiting const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs + windowMs: 15 * 60 * 1000, + max: 100, message: 'Too many requests from this IP', }); app.use(limiter); -// Stricter rate limit for upload endpoint const uploadLimiter = rateLimit({ windowMs: 15 * 60 * 1000, - max: 10, // 10 uploads per 15 minutes + max: 10, message: 'Upload rate limit exceeded', }); -// CORS configuration const allowedOrigins = ( process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:5173' @@ -60,7 +49,6 @@ const allowedOrigins = ( const corsOptions = { origin: (origin, callback) => { - // Allow requests with no origin (like mobile apps or curl requests) if (!origin) return callback(null, true); if ( @@ -89,7 +77,7 @@ app.use(cookieParser()); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/api/auth', authRoutes); -// Ensure uploads directory exists + const uploadsDir = path.join(__dirname, 'uploads'); try { if (!fs.existsSync(uploadsDir)) { @@ -100,15 +88,16 @@ try { process.exit(1); } -// Routes const analyzeRoutes = require('./routes/analyze'); +const recommendationsRoutes = require('./routes/recommendations'); + app.use('/api', uploadLimiter, analyzeRoutes); +app.use('/api', recommendationsRoutes); app.get('/', (req, res) => { res.send('NutriLens Backend is running'); }); -// Global Error Handler app.use((err, req, res, _next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { @@ -129,7 +118,6 @@ app.use((err, req, res, _next) => { res.status(500).json({ error: 'Internal server error' }); }); -// Database Connection and Server Start const connectDB = require('./config/db'); const startServer = async () => { diff --git a/backend/utils/prompts.js b/backend/utils/prompts.js index a63596d..2c6a982 100644 --- a/backend/utils/prompts.js +++ b/backend/utils/prompts.js @@ -1,11 +1,10 @@ -// Text chat prompt const chatPrompt = message => `You are NutriBot, a helpful nutrition assistant. User Message: "${message}" Respond in valid JSON format ONLY: { "text": "Your helpful response text here...", - "report": { // Optional: include ONLY if user describes a specific meal to analyze + "report": { "carbs": 0, "protein": 0, "fats": 0 @@ -21,7 +20,6 @@ Rules: - Don't force a report if not applicable. `; -// Image analysis prompt const analysisPrompt = quantity => `Analyze this food image thoroughly. Identify the food item(s). @@ -71,13 +69,53 @@ Return ONLY valid JSON in the following format (all numeric values MUST be numbe } Notes: - All gram values should be in grams (g) -- Vitamins and minerals in milligrams (mg) or appropriate units (mcg for certain vitamins if standard, but preferably normalize to mg or specify unit if implicit constraints allow - however schema implies Number so stick to standard numerical values, e.g. mg for Sodium/Potassium/Calcium/Iron/Magnesium/Zinc. Vitamin A/D/B12/C usually mg or mcg. Provide best estimate in standard units.) +- Vitamins and minerals in milligrams (mg) or appropriate units - Percentages should be whole numbers (0-100) - healthScore should be 0-100 - Be accurate with portion size estimation based on the User Context provided. - Provide NON-ZERO estimates for micronutrients if reasonable trace amounts exist. Do not just zero them out unless completely absent.`; +const recommendationPrompt = (remainingCalories, remainingProtein, remainingCarbs, remainingFat, recentFoods) => { + const foodContext = recentFoods && recentFoods.length > 0 + ? `The user has recently eaten: ${recentFoods.join(', ')}. Suggest different foods to add variety.` + : 'The user has not logged any meals today yet. Suggest balanced meals for the day.'; + + return `You are a professional nutritionist AI. ${foodContext} + +The user's remaining daily nutritional targets are: +- Calories: ${remainingCalories} kcal +- Protein: ${remainingProtein} g +- Carbs: ${remainingCarbs} g +- Fat: ${remainingFat} g + +Suggest exactly 3 healthy, practical recipes that collectively help the user meet their remaining nutritional goals. Each recipe should be simple to prepare at home. + +Return ONLY valid JSON as an array of exactly 3 objects in the following format (all numeric values MUST be numbers, not strings): +[ + { + "title": "Recipe Name", + "prepTime": "15 mins", + "calories": 0, + "protein": 0, + "carbs": 0, + "fat": 0, + "ingredients": ["ingredient 1 with quantity", "ingredient 2 with quantity"], + "instructions": ["Step 1 description", "Step 2 description"], + "tags": ["high-protein", "low-carb"] + } +] + +Rules: +- Each recipe should target roughly one-third of the remaining daily macros. +- Tags should be relevant descriptors like "high-protein", "low-carb", "low-fat", "quick", "vegan", "gluten-free", etc. +- Keep instructions concise but actionable (3-6 steps per recipe). +- Include 4-8 ingredients per recipe with specific quantities. +- Prep time should be realistic. +- All calorie values in kcal, macros in grams.`; +}; + module.exports = { chatPrompt, analysisPrompt, + recommendationPrompt, }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 12445fa..7366439 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { Navbar } from './components/Navbar'; import { HistorySidebar } from './components/HistorySidebar'; import { Home } from './pages/Home'; import { Analysis } from './pages/Analysis'; +import { Recipes } from './pages/Recipes'; import { getHistory, clearHistory } from './api'; import type { MealData } from './api'; import Footer from './components/Footer'; @@ -29,7 +30,6 @@ const App: React.FC = () => { fetchHistory(); - // Listen for history updates from other components const handleHistoryUpdate = () => { fetchHistory(); }; @@ -51,15 +51,14 @@ const App: React.FC = () => { return (