From 8afae98ce2bd5554c4a5b16c073df21a693e567c Mon Sep 17 00:00:00 2001 From: Raghav Gaba Date: Fri, 5 Sep 2025 13:03:40 +0530 Subject: [PATCH] feat(ai): enhance chatbot with agricultural context and weather data - Centralized weather API service to optimize calls for context and nudges --- backend/controllers/chatController.js | 120 +++++- backend/controllers/nudgesController.js | 19 +- backend/services/contextService.js | 465 ++++++++++++++++++++++++ backend/services/weatherService.js | 385 ++++++++++++++++++++ 4 files changed, 969 insertions(+), 20 deletions(-) create mode 100644 backend/services/contextService.js create mode 100644 backend/services/weatherService.js diff --git a/backend/controllers/chatController.js b/backend/controllers/chatController.js index ffb6278..4d685b3 100644 --- a/backend/controllers/chatController.js +++ b/backend/controllers/chatController.js @@ -4,6 +4,7 @@ const { getModel } = require("../config/openrouter"); const Message = require("../models/Message"); const Thread = require("../models/Thread"); const logger = require("../utils/logger"); +const contextService = require("../services/contextService"); // Send a message const sendMessage = async (req, res) => { @@ -128,22 +129,47 @@ const streamChat = async (req, res) => { }); } - // Get conversation history - const conversationHistory = await Message.getConversationHistory(threadId); + // Build comprehensive context for personalized AI response + logger.info(`Building context for user ${userId} in thread ${threadId}`); + const context = await contextService.buildCompleteContext(userId, threadId, userMessage); + + // Log context availability for debugging + logger.info(`Context built - User: ${!!context.user}, Weather: ${!!context.weather}, Conversation: ${!!context.conversation}, Has Error: ${!!context.metadata.error}`); - // Prepare messages for AI + // Get conversation history (limit to recent messages to save tokens) + const conversationHistory = await Message.getConversationHistory(threadId, 15); + + // Format context for AI prompt + const contextPrompt = contextService.formatContextForAI(context); + + // Prepare enhanced messages for AI with context const messages = [ { role: "system", - content: `You are a Digital Krishi Officer, an AI-powered agricultural advisory assistant. You help farmers with: + content: `You are a Digital Krishi Officer, an AI-powered agricultural advisory assistant designed specifically for farmers. You help farmers with: - Pest and disease management - - Weather-related decisions - - Input optimization (fertilizers, pesticides) + - Weather-related agricultural decisions + - Input optimization (fertilizers, pesticides, seeds) - Government subsidies and schemes - - Market trends and pricing + - Market trends and pricing information - Crop planning and seasonal guidance + - Irrigation and water management + - Soil health and nutrition + + IMPORTANT INSTRUCTIONS: + - Always consider the farmer's location, weather conditions, and experience level + - Provide practical, actionable advice that can be implemented immediately + - Use simple, clear language appropriate for the farmer's experience level + - Include weather considerations in your recommendations when relevant + - Suggest seasonal activities appropriate for the current time of year + - If you're unsure about region-specific advice, recommend consulting local agricultural experts + - Be empathetic and supportive - farming is challenging work + + ${contextPrompt} + + Current farmer's question: "${userMessage}" - Provide helpful, accurate, and practical advice. Be concise but thorough. If you're unsure about something, recommend consulting with local agricultural experts.`, + Provide helpful, accurate, and practical advice based on all the context provided above.`, }, ...conversationHistory.map((msg) => ({ role: msg.role, @@ -167,7 +193,7 @@ const streamChat = async (req, res) => { // Get AI model const aiModel = getModel(model); - // Create assistant message placeholder + // Create assistant message placeholder with context metadata const assistantMsg = new Message({ threadId, userId, @@ -176,6 +202,16 @@ const streamChat = async (req, res) => { status: "processing", metadata: { model: model || "google/gemini-2.5-flash-lite", + hasContext: true, + contextMetadata: { + hasUserData: context.metadata.hasUserData, + hasWeatherData: context.metadata.hasWeatherData, + hasConversationData: context.metadata.hasConversationData, + contextBuiltAt: context.metadata.contextBuiltAt, + userLocation: context.user?.location || 'Not specified', + currentSeason: context.seasonal?.currentSeason || 'unknown', + weatherConditions: context.weather?.conditions || 'Not available' + } }, }); await assistantMsg.save(); @@ -273,13 +309,19 @@ const streamChat = async (req, res) => { } logger.info( - `AI stream completed for thread ${threadId}, total response length: ${fullResponse.length}` + `AI stream completed for thread ${threadId}, total response length: ${fullResponse.length}, context used: ${!!context.user || !!context.weather}` ); - // Update assistant message with full response + // Update assistant message with full response and context info assistantMsg.content = fullResponse; assistantMsg.status = "completed"; assistantMsg.metadata.processingTime = Date.now() - startTime; + assistantMsg.metadata.contextUsed = { + userContext: !!context.user, + weatherContext: !!context.weather, + conversationContext: !!context.conversation, + totalContextSources: [context.user, context.weather, context.conversation].filter(Boolean).length + }; // Get usage info if available if (result.usage) { @@ -308,7 +350,12 @@ const streamChat = async (req, res) => { }); } - logger.info(`AI response generated for thread ${threadId}`); + logger.info(`AI response generated for thread ${threadId} with enhanced context`); + + // Pre-load weather data for future requests (async, don't wait) + contextService.preloadUserWeather(userId).catch(err => + logger.debug(`Weather pre-load failed for user ${userId}: ${err.message}`) + ); } catch (aiError) { logger.error(`AI streaming error: ${aiError.message}`); @@ -504,6 +551,54 @@ const removeReaction = async (req, res) => { } }; +// Get context information for debugging (useful for development) +const getContextInfo = async (req, res) => { + try { + const { threadId } = req.params; + const userId = req.user._id; + + // Verify thread exists and belongs to user + const thread = await Thread.findOne({ _id: threadId, userId }); + if (!thread) { + return res.status(404).json({ + success: false, + message: "Thread not found", + }); + } + + // Build context for this user/thread + const context = await contextService.buildCompleteContext(userId, threadId, "Debug context request"); + + // Get cache statistics + const cacheStats = contextService.getCacheStats(); + + res.json({ + success: true, + data: { + context: { + user: context.user, + weather: context.weather, + seasonal: context.seasonal, + conversation: { + category: context.conversation?.threadCategory, + messageCount: context.conversation?.messageCount, + urgencyLevel: context.conversation?.urgencyLevel + }, + metadata: context.metadata + }, + cacheStats, + formattedPrompt: contextService.formatContextForAI(context) + } + }); + } catch (error) { + logger.error(`Get context info error: ${error.message}`); + res.status(500).json({ + success: false, + message: "Failed to get context information", + }); + } +}; + module.exports = { sendMessage, streamChat, @@ -512,4 +607,5 @@ module.exports = { editMessage, addReaction, removeReaction, + getContextInfo, }; diff --git a/backend/controllers/nudgesController.js b/backend/controllers/nudgesController.js index 2d42497..2363e9b 100644 --- a/backend/controllers/nudgesController.js +++ b/backend/controllers/nudgesController.js @@ -1,7 +1,6 @@ -const axios = require("axios"); const { generateText } = require("ai"); const { getModel } = require("../config/openrouter"); -const { WEATHER_API_URL, WEATHER_API_KEY } = require("../config/weatherConfig"); +const weatherService = require("../services/weatherService"); const getNudges = async (req, res) => { try { @@ -12,12 +11,16 @@ const getNudges = async (req, res) => { .json({ error: "Please provide crop and location" }); } - // 1. Fetch weather data - const weatherRes = await axios.get(WEATHER_API_URL, { - params: { q: location, appid: WEATHER_API_KEY, units: "metric" }, - }); - const weatherData = weatherRes.data; - const temp = weatherData.main.temp; + // 1. Fetch weather data using shared weather service + const weatherData = await weatherService.getWeatherForNudges(location); + + if (!weatherData) { + return res + .status(400) + .json({ error: "Unable to fetch weather data for the provided location" }); + } + + const temp = weatherData.temp; const humidity = weatherData.main.humidity; const conditions = weatherData.weather[0].description; diff --git a/backend/services/contextService.js b/backend/services/contextService.js new file mode 100644 index 0000000..4a6cc30 --- /dev/null +++ b/backend/services/contextService.js @@ -0,0 +1,465 @@ +const axios = require('axios'); +const User = require('../models/User'); +const Thread = require('../models/Thread'); +const Message = require('../models/Message'); +const logger = require('../utils/logger'); +const weatherService = require('./weatherService'); + +/** + * In-Memory Cache System + * Manages cached data with automatic expiration and cleanup + */ +class ContextCache { + constructor() { + this.cache = new Map(); + this.maxSize = 1000; // Maximum cache entries + this.stats = { + hits: 0, + misses: 0, + sets: 0 + }; + + // Clean up expired entries every 5 minutes + this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000); + + logger.info('Context cache initialized'); + } + + /** + * Store data in cache with TTL (Time To Live) + * @param {string} key - Cache key + * @param {any} data - Data to cache + * @param {number} ttlMinutes - Time to live in minutes + */ + set(key, data, ttlMinutes = 60) { + // If cache is full, remove oldest entries (simple LRU) + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + + this.cache.set(key, { + data, + expires: Date.now() + (ttlMinutes * 60 * 1000), + lastAccessed: Date.now() + }); + + this.stats.sets++; + logger.debug(`Cache SET: ${key} (TTL: ${ttlMinutes}m)`); + } + + /** + * Retrieve data from cache + * @param {string} key - Cache key + * @returns {any|null} Cached data or null if not found/expired + */ + get(key) { + const item = this.cache.get(key); + + if (!item) { + this.stats.misses++; + logger.debug(`Cache MISS: ${key}`); + return null; + } + + // Check if expired + if (item.expires < Date.now()) { + this.cache.delete(key); + this.stats.misses++; + logger.debug(`Cache EXPIRED: ${key}`); + return null; + } + + // Update last accessed time for LRU + item.lastAccessed = Date.now(); + this.stats.hits++; + logger.debug(`Cache HIT: ${key}`); + return item.data; + } + + /** + * Remove expired entries and log cache statistics + */ + cleanup() { + const now = Date.now(); + let removedCount = 0; + + for (const [key, item] of this.cache.entries()) { + if (item.expires < now) { + this.cache.delete(key); + removedCount++; + } + } + + if (removedCount > 0) { + logger.info(`Cache cleanup: removed ${removedCount} expired entries`); + } + + // Log cache statistics + const hitRate = this.stats.hits / (this.stats.hits + this.stats.misses) * 100; + logger.info(`Cache stats - Size: ${this.cache.size}, Hit rate: ${hitRate.toFixed(2)}%`); + } + + /** + * Clear all cache entries + */ + clear() { + this.cache.clear(); + this.stats = { hits: 0, misses: 0, sets: 0 }; + logger.info('Cache cleared'); + } + + /** + * Get cache statistics + */ + getStats() { + return { + ...this.stats, + size: this.cache.size, + hitRate: this.stats.hits / (this.stats.hits + this.stats.misses) * 100 + }; + } +} + +// Global cache instance +const cache = new ContextCache(); + +/** + * Context Service + * Gathers and manages contextual information for AI conversations + */ +class ContextService { + + /** + * Get user context from profile data + * @param {string} userId - User ID + * @returns {Object} User context data + */ + async getUserContext(userId) { + const cacheKey = `user:${userId}`; + + // Try to get from cache first + let userContext = cache.get(cacheKey); + if (userContext) { + return userContext; + } + + try { + const user = await User.findById(userId).select('-password'); + if (!user) { + logger.warn(`User not found: ${userId}`); + return null; + } + + userContext = { + location: user.profile?.location || 'Not specified', + farmSize: user.profile?.farmSize || 'Not specified', + cropTypes: user.profile?.cropTypes || [], + experience: user.profile?.experience || 'beginner', + language: user.preferences?.language || 'en', + firstName: user.profile?.firstName || user.username + }; + + // Cache for 24 hours (user data doesn't change frequently) + cache.set(cacheKey, userContext, 24 * 60); + + logger.debug(`User context retrieved for: ${userId}`); + return userContext; + + } catch (error) { + logger.error(`Error getting user context: ${error.message}`); + return null; + } + } + + /** + * Get weather context for a location using shared weather service + * @param {string} location - Location name + * @returns {Object} Weather context data + */ + async getWeatherContext(location) { + if (!location || location === 'Not specified') { + return null; + } + + try { + // Use shared weather service instead of direct API calls + const weatherData = await weatherService.getWeatherForContext(location); + + if (!weatherData) { + logger.debug(`No weather data available for: ${location}`); + return null; + } + + logger.debug(`Weather context retrieved via shared service for: ${location}`); + return weatherData; + + } catch (error) { + logger.error(`Error getting weather context for ${location}: ${error.message}`); + return null; + } + } + + /** + * Get conversation context from recent messages + * @param {string} threadId - Thread ID + * @param {number} limit - Number of recent messages to include + * @returns {Object} Conversation context + */ + async getConversationContext(threadId, limit = 10) { + const cacheKey = `conversation:${threadId}:${limit}`; + + // Try to get from cache first + let conversationContext = cache.get(cacheKey); + if (conversationContext) { + return conversationContext; + } + + try { + // Get thread information + const thread = await Thread.findById(threadId).select('category title description metadata'); + if (!thread) { + logger.warn(`Thread not found: ${threadId}`); + return null; + } + + // Get recent messages + const messages = await Message.find({ + threadId, + isVisible: true, + role: { $in: ['user', 'assistant'] } + }) + .select('role content createdOn') + .sort({ createdOn: -1 }) + .limit(limit); + + conversationContext = { + threadCategory: thread.category, + threadTitle: thread.title, + threadDescription: thread.description, + cropType: thread.metadata?.cropType, + season: thread.metadata?.season, + urgencyLevel: thread.metadata?.urgencyLevel || 3, + messageCount: messages.length, + recentMessages: messages.reverse().map(msg => ({ + role: msg.role, + content: msg.content.substring(0, 200), // Limit message length for context + timestamp: msg.createdOn + })) + }; + + // Cache for 30 minutes (conversation context changes more frequently) + cache.set(cacheKey, conversationContext, 30); + + logger.debug(`Conversation context retrieved for thread: ${threadId}`); + return conversationContext; + + } catch (error) { + logger.error(`Error getting conversation context: ${error.message}`); + return null; + } + } + + /** + * Get seasonal context based on location and current date + * @param {string} location - Location name + * @param {Date} date - Current date + * @returns {Object} Seasonal context + */ + getSeasonalContext(location, date = new Date()) { + const month = date.getMonth() + 1; // JavaScript months are 0-indexed + + // Indian agricultural seasons (adjust based on your target region) + let season = 'unknown'; + let activities = []; + + if (month >= 6 && month <= 10) { + season = 'kharif'; // Monsoon season + activities = ['planting rice', 'cotton cultivation', 'pest monitoring during rains']; + } else if (month >= 11 || month <= 3) { + season = 'rabi'; // Winter season + activities = ['wheat cultivation', 'irrigation management', 'harvest preparation']; + } else { + season = 'zaid'; // Summer season + activities = ['summer crops', 'water conservation', 'heat stress management']; + } + + return { + currentSeason: season, + month: month, + suggestedActivities: activities, + isPlantingSeason: season === 'kharif' || season === 'rabi', + isHarvestSeason: (season === 'rabi' && month >= 2) || (season === 'kharif' && month >= 9) + }; + } + + /** + * Build complete context for AI conversation + * @param {string} userId - User ID + * @param {string} threadId - Thread ID + * @param {string} userMessage - User's current message + * @returns {Object} Complete context object + */ + async buildCompleteContext(userId, threadId, userMessage) { + try { + logger.info(`Building context for user: ${userId}, thread: ${threadId}`); + + // Gather all context data in parallel for better performance + const [userContext, conversationContext] = await Promise.all([ + this.getUserContext(userId), + this.getConversationContext(threadId) + ]); + + // Get weather context if user location is available + // The shared weather service will handle caching automatically + let weatherContext = null; + if (userContext?.location && userContext.location !== 'Not specified') { + weatherContext = await this.getWeatherContext(userContext.location); + } + + // Get seasonal context + const seasonalContext = this.getSeasonalContext(userContext?.location); + + // Build the complete context object + const context = { + user: userContext, + weather: weatherContext, + conversation: conversationContext, + seasonal: seasonalContext, + currentMessage: { + content: userMessage, + timestamp: new Date() + }, + metadata: { + contextBuiltAt: new Date(), + hasWeatherData: !!weatherContext, + hasUserData: !!userContext, + hasConversationData: !!conversationContext + } + }; + + logger.info(`Context built successfully - Weather: ${!!weatherContext}, User: ${!!userContext}, Conversation: ${!!conversationContext}`); + return context; + + } catch (error) { + logger.error(`Error building complete context: ${error.message}`); + + // Return minimal context to ensure chat still works + return { + user: null, + weather: null, + conversation: null, + seasonal: this.getSeasonalContext(), + currentMessage: { + content: userMessage, + timestamp: new Date() + }, + metadata: { + contextBuiltAt: new Date(), + hasWeatherData: false, + hasUserData: false, + hasConversationData: false, + error: error.message + } + }; + } + } + + /** + * Pre-load weather data for a user to optimize future context building + * @param {string} userId - User ID + * @returns {Promise} Resolves when weather is pre-loaded + */ + async preloadUserWeather(userId) { + try { + const userContext = await this.getUserContext(userId); + if (userContext?.location && userContext.location !== 'Not specified') { + await weatherService.preloadWeather(userContext.location); + logger.debug(`Weather pre-loaded for user ${userId} at location: ${userContext.location}`); + } + } catch (error) { + logger.warn(`Failed to pre-load weather for user ${userId}: ${error.message}`); + } + } + + /** + * Format context for AI prompt + * @param {Object} context - Complete context object + * @returns {string} Formatted context string for AI + */ + formatContextForAI(context) { + let contextPrompt = "FARMER CONTEXT:\n"; + + // User context + if (context.user) { + contextPrompt += `Farmer: ${context.user.firstName} (${context.user.experience} level)\n`; + contextPrompt += `Location: ${context.user.location}\n`; + contextPrompt += `Farm Size: ${context.user.farmSize}\n`; + if (context.user.cropTypes.length > 0) { + contextPrompt += `Crops: ${context.user.cropTypes.join(', ')}\n`; + } + contextPrompt += `Language: ${context.user.language}\n`; + } + + // Weather context + if (context.weather) { + contextPrompt += `\nCURRENT WEATHER:\n`; + contextPrompt += `Location: ${context.weather.location}, ${context.weather.country}\n`; + contextPrompt += `Temperature: ${context.weather.temperature}°C\n`; + contextPrompt += `Conditions: ${context.weather.conditions}\n`; + contextPrompt += `Humidity: ${context.weather.humidity}%\n`; + contextPrompt += `Wind Speed: ${context.weather.windSpeed} m/s\n`; + } + + // Seasonal context + contextPrompt += `\nSEASONAL INFO:\n`; + contextPrompt += `Current Season: ${context.seasonal.currentSeason}\n`; + contextPrompt += `Month: ${context.seasonal.month}\n`; + contextPrompt += `Typical Activities: ${context.seasonal.suggestedActivities.join(', ')}\n`; + + // Conversation context + if (context.conversation) { + contextPrompt += `\nCONVERSATION CONTEXT:\n`; + contextPrompt += `Topic: ${context.conversation.threadCategory}\n`; + contextPrompt += `Title: ${context.conversation.threadTitle}\n`; + if (context.conversation.cropType) { + contextPrompt += `Specific Crop: ${context.conversation.cropType}\n`; + } + if (context.conversation.urgencyLevel > 3) { + contextPrompt += `URGENT: This is a high priority query\n`; + } + } + + contextPrompt += `\nINSTRUCTIONS:\n`; + contextPrompt += `- Provide advice specific to the farmer's location and weather conditions\n`; + contextPrompt += `- Consider the farmer's experience level in your response\n`; + contextPrompt += `- Focus on the current season and appropriate activities\n`; + contextPrompt += `- Be practical and actionable\n`; + contextPrompt += `- Use simple language appropriate for the farmer's experience level\n\n`; + + return contextPrompt; + } + + /** + * Get cache statistics (includes both context cache and weather cache) + */ + getCacheStats() { + return { + contextCache: cache.getStats(), + weatherCache: weatherService.getCacheStats() + }; + } + + /** + * Clear cache (useful for testing) + */ + clearCache() { + cache.clear(); + weatherService.clearCache(); + } +} + +// Export singleton instance +const contextService = new ContextService(); + +module.exports = contextService; diff --git a/backend/services/weatherService.js b/backend/services/weatherService.js new file mode 100644 index 0000000..cb7cf1e --- /dev/null +++ b/backend/services/weatherService.js @@ -0,0 +1,385 @@ +const axios = require('axios'); +const logger = require('../utils/logger'); +const { WEATHER_API_URL, WEATHER_API_KEY } = require('../config/weatherConfig'); + +/** + * Shared Weather Cache + * Single cache instance used across all weather-related services + */ +class WeatherCache { + constructor() { + this.cache = new Map(); + this.maxSize = 500; // Smaller cache focused on weather data + this.stats = { + hits: 0, + misses: 0, + sets: 0 + }; + + // Clean up expired entries every 10 minutes + this.cleanupInterval = setInterval(() => this.cleanup(), 10 * 60 * 1000); + + logger.info('Weather cache initialized'); + } + + /** + * Store weather data in cache + * @param {string} location - Location name (will be normalized) + * @param {Object} data - Weather data to cache + * @param {number} ttlMinutes - Time to live in minutes (default: 60) + */ + set(location, data, ttlMinutes = 60) { + const key = this.normalizeLocation(location); + + // Remove oldest entry if cache is full + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + + this.cache.set(key, { + data, + expires: Date.now() + (ttlMinutes * 60 * 1000), + lastAccessed: Date.now(), + location: location // Store original location for reference + }); + + this.stats.sets++; + logger.debug(`Weather cache SET: ${key} (TTL: ${ttlMinutes}m)`); + } + + /** + * Retrieve weather data from cache + * @param {string} location - Location name + * @returns {Object|null} Weather data or null if not found/expired + */ + get(location) { + const key = this.normalizeLocation(location); + const item = this.cache.get(key); + + if (!item) { + this.stats.misses++; + logger.debug(`Weather cache MISS: ${key}`); + return null; + } + + // Check if expired + if (item.expires < Date.now()) { + this.cache.delete(key); + this.stats.misses++; + logger.debug(`Weather cache EXPIRED: ${key}`); + return null; + } + + // Update last accessed time + item.lastAccessed = Date.now(); + this.stats.hits++; + logger.debug(`Weather cache HIT: ${key}`); + return item.data; + } + + /** + * Normalize location string for consistent caching + * @param {string} location - Location name + * @returns {string} Normalized location key + */ + normalizeLocation(location) { + return location.toLowerCase().trim().replace(/\s+/g, '_'); + } + + /** + * Clean up expired entries + */ + cleanup() { + const now = Date.now(); + let removedCount = 0; + + for (const [key, item] of this.cache.entries()) { + if (item.expires < now) { + this.cache.delete(key); + removedCount++; + } + } + + if (removedCount > 0) { + logger.info(`Weather cache cleanup: removed ${removedCount} expired entries`); + } + + // Log cache statistics + const hitRate = this.stats.hits / (this.stats.hits + this.stats.misses) * 100; + logger.info(`Weather cache stats - Size: ${this.cache.size}, Hit rate: ${hitRate.toFixed(2)}%`); + } + + /** + * Get cache statistics + */ + getStats() { + return { + ...this.stats, + size: this.cache.size, + hitRate: this.stats.hits / (this.stats.hits + this.stats.misses) * 100 + }; + } + + /** + * Clear all cache entries + */ + clear() { + this.cache.clear(); + this.stats = { hits: 0, misses: 0, sets: 0 }; + logger.info('Weather cache cleared'); + } +} + +// Global weather cache instance +const weatherCache = new WeatherCache(); + +/** + * Centralized Weather Service + * Single source of truth for all weather data across the application + */ +class WeatherService { + + /** + * Get weather data for a location with smart caching + * @param {string} location - Location name (city, state, country) + * @param {Object} options - Optional parameters + * @returns {Object|null} Weather data or null if unavailable + */ + async getWeatherData(location, options = {}) { + // Validate input + if (!location || typeof location !== 'string' || location.trim() === '' || location === 'Not specified') { + logger.warn('Invalid location provided to weather service'); + return null; + } + + const normalizedLocation = location.trim(); + + // Try cache first + const cachedWeather = weatherCache.get(normalizedLocation); + if (cachedWeather) { + logger.debug(`Weather data served from cache for: ${normalizedLocation}`); + return cachedWeather; + } + + // Fetch from API + try { + logger.info(`Fetching weather data from API for: ${normalizedLocation}`); + + const response = await axios.get(WEATHER_API_URL, { + params: { + q: normalizedLocation, + appid: WEATHER_API_KEY, + units: options.units || 'metric' + }, + timeout: options.timeout || 5000 + }); + + const weatherData = response.data; + + // Standardize weather data format + const standardizedWeather = this.standardizeWeatherData(weatherData); + + // Cache the result (1 hour TTL) + weatherCache.set(normalizedLocation, standardizedWeather, 60); + + logger.info(`Weather data fetched and cached for: ${normalizedLocation}`); + return standardizedWeather; + + } catch (error) { + this.handleWeatherAPIError(error, normalizedLocation); + return null; + } + } + + /** + * Standardize weather data format for consistent usage across services + * @param {Object} apiResponse - Raw API response from OpenWeatherMap + * @returns {Object} Standardized weather data + */ + standardizeWeatherData(apiResponse) { + return { + // Basic weather info + temperature: Math.round(apiResponse.main.temp), + humidity: apiResponse.main.humidity, + conditions: apiResponse.weather[0].description, + + // Wind and pressure + windSpeed: apiResponse.wind?.speed || 0, + windDirection: apiResponse.wind?.deg || null, + pressure: apiResponse.main.pressure, + + // Visibility and additional data + visibility: apiResponse.visibility ? Math.round(apiResponse.visibility / 1000) : null, // Convert to km + cloudiness: apiResponse.clouds?.all || 0, + + // Location info + location: apiResponse.name, + country: apiResponse.sys.country, + coordinates: { + lat: apiResponse.coord.lat, + lon: apiResponse.coord.lon + }, + + // Timestamps + sunrise: new Date(apiResponse.sys.sunrise * 1000), + sunset: new Date(apiResponse.sys.sunset * 1000), + dataTime: new Date(apiResponse.dt * 1000), + fetchedAt: new Date(), + + // Additional computed fields + feelsLike: Math.round(apiResponse.main.feels_like), + tempMin: Math.round(apiResponse.main.temp_min), + tempMax: Math.round(apiResponse.main.temp_max), + + // Raw data for advanced usage + _raw: { + id: apiResponse.weather[0].id, + main: apiResponse.weather[0].main, + icon: apiResponse.weather[0].icon + } + }; + } + + /** + * Handle weather API errors with appropriate logging and classification + * @param {Error} error - Error from API call + * @param {string} location - Location that was requested + */ + handleWeatherAPIError(error, location) { + if (error.response) { + // API responded with error status + const status = error.response.status; + const message = error.response.data?.message || 'Unknown API error'; + + switch (status) { + case 401: + logger.error(`Weather API authentication failed - check API key`); + break; + case 404: + logger.warn(`Location not found in weather API: ${location}`); + break; + case 429: + logger.warn(`Weather API rate limit exceeded for: ${location}`); + break; + default: + logger.error(`Weather API error (${status}): ${message} for location: ${location}`); + } + } else if (error.code === 'ECONNABORTED') { + logger.warn(`Weather API timeout for location: ${location}`); + } else { + logger.error(`Weather service error for ${location}: ${error.message}`); + } + } + + /** + * Get weather data formatted for nudges (backward compatibility) + * @param {string} location - Location name + * @returns {Object|null} Weather data in nudges format + */ + async getWeatherForNudges(location) { + const weatherData = await this.getWeatherData(location); + if (!weatherData) return null; + + // Return format expected by nudges controller + return { + temperature: `${weatherData.temperature}°C`, + humidity: `${weatherData.humidity}%`, + conditions: weatherData.conditions, + temp: weatherData.temperature, // For backward compatibility + main: { + temp: weatherData.temperature, + humidity: weatherData.humidity + }, + weather: [{ + description: weatherData.conditions + }] + }; + } + + /** + * Get weather data formatted for context service + * @param {string} location - Location name + * @returns {Object|null} Weather data in context format + */ + async getWeatherForContext(location) { + const weatherData = await this.getWeatherData(location); + if (!weatherData) return null; + + // Return format expected by context service + return { + temperature: weatherData.temperature, + humidity: weatherData.humidity, + conditions: weatherData.conditions, + windSpeed: weatherData.windSpeed, + pressure: weatherData.pressure, + visibility: weatherData.visibility, + location: weatherData.location, + country: weatherData.country + }; + } + + /** + * Pre-load weather data for a user's location + * @param {string} location - User's location + * @returns {Promise} Resolves when weather is cached + */ + async preloadWeather(location) { + if (!location || location === 'Not specified') return; + + try { + await this.getWeatherData(location); + logger.debug(`Weather pre-loaded for: ${location}`); + } catch (error) { + logger.warn(`Failed to pre-load weather for: ${location}`); + } + } + + /** + * Bulk pre-load weather for multiple locations + * @param {string[]} locations - Array of location names + * @returns {Promise} Resolves when all locations are processed + */ + async preloadMultipleWeather(locations) { + const validLocations = locations.filter(loc => loc && loc !== 'Not specified'); + + if (validLocations.length === 0) return; + + logger.info(`Pre-loading weather for ${validLocations.length} locations`); + + // Process in batches to avoid overwhelming the API + const batchSize = 5; + for (let i = 0; i < validLocations.length; i += batchSize) { + const batch = validLocations.slice(i, i + batchSize); + + await Promise.allSettled( + batch.map(location => this.preloadWeather(location)) + ); + + // Small delay between batches to be respectful to the API + if (i + batchSize < validLocations.length) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + } + + /** + * Get cache statistics + * @returns {Object} Cache statistics + */ + getCacheStats() { + return weatherCache.getStats(); + } + + /** + * Clear weather cache + */ + clearCache() { + weatherCache.clear(); + } +} + +// Export singleton instance +const weatherService = new WeatherService(); + +module.exports = weatherService; \ No newline at end of file