""" Environment data service for Tools domain. Fetches weather, forecast, sun times, and air quality data from the Qdrant volatile collection. """ from datetime import datetime from typing import Optional, Dict, Any, List from src.shared.logging import get_logger from src.shared.clients.qdrant_client import get_qdrant_client from src.domains.tools.environment.schemas import ( WeatherData, ForecastDay, SunTimesData, AirQualityData, EnvironmentResponse, ) logger = get_logger(__name__) class EnvironmentService: """ Service for fetching environment data from Qdrant volatile collection. Retrieves weather, forecast, sun times, and optionally air quality data for a specific user. """ def __init__(self): """Initialize environment service with Qdrant client.""" self.qdrant = get_qdrant_client() def _parse_weather(self, raw_data: Optional[Dict[str, Any]]) -> Optional[WeatherData]: """ Parse raw weather data into WeatherData schema. Handles various field naming conventions that might come from different weather APIs. """ if not raw_data: return None try: return WeatherData( temperature=raw_data.get("temperature") or raw_data.get("temp"), feels_like=raw_data.get("feels_like") or raw_data.get("feelslike"), conditions=raw_data.get("conditions") or raw_data.get("weather") or raw_data.get("description"), humidity=raw_data.get("humidity"), wind_speed=raw_data.get("wind_speed") or raw_data.get("windspeed") or raw_data.get("wind"), wind_direction=raw_data.get("wind_direction") or raw_data.get("wind_dir"), pressure=raw_data.get("pressure"), visibility=raw_data.get("visibility"), uv_index=raw_data.get("uv_index") or raw_data.get("uv"), location=raw_data.get("location") or raw_data.get("city"), icon=raw_data.get("icon") or raw_data.get("icon_url"), ) except Exception as e: logger.warning(f"Failed to parse weather data: {e}") return None def _parse_forecast(self, raw_data: Any) -> Optional[List[ForecastDay]]: """ Parse raw forecast data into list of ForecastDay schemas. Handles both list format and dict with nested list. """ if not raw_data: return None try: # Normalize to list forecast_list = raw_data if isinstance(raw_data, dict): forecast_list = raw_data.get("days") or raw_data.get("forecast") or [] if not isinstance(forecast_list, list): return None days = [] for day in forecast_list: if isinstance(day, dict): days.append(ForecastDay( date=day.get("date", ""), high=day.get("high") or day.get("maxtemp") or day.get("temp_max"), low=day.get("low") or day.get("mintemp") or day.get("temp_min"), conditions=day.get("conditions") or day.get("weather") or day.get("description"), precipitation_chance=day.get("precipitation_chance") or day.get("pop") or day.get("precip"), icon=day.get("icon"), )) return days if days else None except Exception as e: logger.warning(f"Failed to parse forecast data: {e}") return None def _parse_sun_times(self, raw_data: Optional[Dict[str, Any]]) -> Optional[SunTimesData]: """ Parse raw sun times data into SunTimesData schema. Handles datetime strings and calculates daylight minutes if not provided. """ if not raw_data: return None try: sunrise = raw_data.get("sunrise") sunset = raw_data.get("sunset") # Parse datetime strings if needed if isinstance(sunrise, str): sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00")) if isinstance(sunset, str): sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00")) # Calculate daylight minutes if not provided daylight_minutes = raw_data.get("daylight_minutes") or raw_data.get("daylight") if daylight_minutes is None and sunrise and sunset: daylight_minutes = int((sunset - sunrise).total_seconds() / 60) # Parse optional fields solar_noon = raw_data.get("solar_noon") if isinstance(solar_noon, str): solar_noon = datetime.fromisoformat(solar_noon.replace("Z", "+00:00")) dawn = raw_data.get("dawn") or raw_data.get("civil_dawn") if isinstance(dawn, str): dawn = datetime.fromisoformat(dawn.replace("Z", "+00:00")) dusk = raw_data.get("dusk") or raw_data.get("civil_dusk") if isinstance(dusk, str): dusk = datetime.fromisoformat(dusk.replace("Z", "+00:00")) return SunTimesData( sunrise=sunrise, sunset=sunset, daylight_minutes=daylight_minutes, solar_noon=solar_noon, dawn=dawn, dusk=dusk, ) except Exception as e: logger.warning(f"Failed to parse sun times data: {e}") return None def _parse_air_quality(self, raw_data: Any) -> Optional[AirQualityData]: """ Parse raw air quality data into AirQualityData schema. Handles both dict format and simple integer AQI value. """ if raw_data is None: return None try: # Handle simple integer AQI if isinstance(raw_data, (int, float)): aqi = int(raw_data) return AirQualityData( aqi=aqi, quality=self._aqi_to_quality(aqi), ) if not isinstance(raw_data, dict): return None aqi = raw_data.get("aqi") or raw_data.get("index") if isinstance(aqi, (int, float)): aqi = int(aqi) return AirQualityData( aqi=aqi, quality=raw_data.get("quality") or (self._aqi_to_quality(aqi) if aqi else None), pm25=raw_data.get("pm25") or raw_data.get("pm2_5"), pm10=raw_data.get("pm10"), o3=raw_data.get("o3") or raw_data.get("ozone"), no2=raw_data.get("no2"), location=raw_data.get("location"), ) except Exception as e: logger.warning(f"Failed to parse air quality data: {e}") return None def _aqi_to_quality(self, aqi: int) -> str: """Convert AQI value to quality category string.""" if aqi <= 50: return "Good" elif aqi <= 100: return "Moderate" elif aqi <= 150: return "Unhealthy for Sensitive Groups" elif aqi <= 200: return "Unhealthy" elif aqi <= 300: return "Very Unhealthy" else: return "Hazardous" async def get_current(self, user: str = "default") -> EnvironmentResponse: """ Get current environment data for a user. Fetches weather, forecast, sun times, and air quality from the user's volatile collection. Args: user: User identifier (default: 'default') Returns: EnvironmentResponse with all available data """ logger.info(f"Fetching environment data for user: {user}") # Get raw data from Qdrant raw_data = await self.qdrant.get_environment_data(user) # Parse each data type weather = self._parse_weather(raw_data.get("weather")) forecast = self._parse_forecast(raw_data.get("forecast")) sun_times = self._parse_sun_times(raw_data.get("sun_times")) air_quality = self._parse_air_quality(raw_data.get("air_quality")) return EnvironmentResponse( weather=weather, forecast=forecast, sun_times=sun_times, air_quality=air_quality, updated_at=datetime.utcnow(), user=user, ) # Singleton instance _environment_service: Optional[EnvironmentService] = None def get_environment_service() -> EnvironmentService: """Get or create singleton environment service instance.""" global _environment_service if _environment_service is None: _environment_service = EnvironmentService() return _environment_service