Add GET /tools/environment endpoint that fetches weather, forecast, sun times, and air quality data from user's volatile Qdrant collection. - Add qdrant-client dependency - Create QdrantReadClient wrapper for read-only queries - Add environment schemas and service in tools domain - Parse weather, forecast, sun times, and air quality from Qdrant payloads - Support user-specific collections via preferred_username from OIDC - Add comprehensive service tests (13 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
247 lines
8.7 KiB
Python
247 lines
8.7 KiB
Python
"""
|
|
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
|