feat: add environment endpoint for weather, forecast, and sun data
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>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
b8fca7060f
commit
6045c6ac6a
@@ -4,8 +4,10 @@ Tools Controller
|
||||
Provides utility tool endpoints including:
|
||||
- DNS lookups
|
||||
- System stats
|
||||
- Environment data (weather, forecast, sun times)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from typing import Dict, Optional
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.logging import get_logger
|
||||
@@ -14,6 +16,9 @@ from src.domains.tools.dns.service import DNSService
|
||||
from src.domains.tools.dns.exceptions import DNSQueryError
|
||||
from src.domains.tools.system.schemas import SystemStatsResponse
|
||||
from src.domains.tools.system.service import SystemStatsService
|
||||
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||
from src.domains.tools.environment.service import EnvironmentService
|
||||
from src.domains.auth.oidc import get_optional_user
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -25,12 +30,14 @@ class ToolsController(BaseController):
|
||||
Provides endpoints for:
|
||||
- DNS lookups
|
||||
- System stats
|
||||
- Environment data (weather, forecast, sun times)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/tools", tags=["Tools"])
|
||||
self.dns_service = DNSService()
|
||||
self.system_stats_service = SystemStatsService()
|
||||
self.environment_service = EnvironmentService()
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
@@ -146,6 +153,63 @@ class ToolsController(BaseController):
|
||||
detail=f"Failed to collect system stats: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/environment",
|
||||
response_model=EnvironmentResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Get environment data",
|
||||
description="""
|
||||
Get current environment data including weather, forecast, and sun times.
|
||||
|
||||
Fetches data from the Qdrant volatile collection for the authenticated user.
|
||||
Falls back to 'default' user if not authenticated.
|
||||
|
||||
**Data Returned:**
|
||||
- **Weather:** Current temperature, conditions, humidity, wind
|
||||
- **Forecast:** Multi-day weather outlook
|
||||
- **Sun Times:** Sunrise, sunset, daylight duration
|
||||
- **Air Quality:** AQI and pollutant levels (if available)
|
||||
|
||||
**Data Source:** Qdrant volatile_{user} collection
|
||||
|
||||
**Use Cases:**
|
||||
- Dashboard environment widgets
|
||||
- Home automation context
|
||||
- Weather-based automations
|
||||
"""
|
||||
)
|
||||
async def get_environment(
|
||||
user: Optional[Dict] = Depends(get_optional_user),
|
||||
) -> EnvironmentResponse:
|
||||
"""
|
||||
Get current environment data
|
||||
|
||||
Args:
|
||||
user: Optional authenticated user from OIDC
|
||||
|
||||
Returns:
|
||||
Environment data including weather, forecast, sun times
|
||||
|
||||
Raises:
|
||||
HTTPException: 500 for processing errors
|
||||
"""
|
||||
try:
|
||||
# Get user identifier from OIDC claims, fallback to 'default'
|
||||
user_id = "default"
|
||||
if user:
|
||||
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||
|
||||
logger.info(f"Fetching environment data for user: {user_id}")
|
||||
result = await self.environment_service.get_current(user_id)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get environment data: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch environment data: {str(e)}"
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Environment data module for Tools domain.
|
||||
|
||||
Provides access to weather, forecast, sun times, and air quality data
|
||||
from the Qdrant volatile collection.
|
||||
"""
|
||||
from src.domains.tools.environment.schemas import (
|
||||
WeatherData,
|
||||
ForecastDay,
|
||||
SunTimesData,
|
||||
AirQualityData,
|
||||
EnvironmentResponse,
|
||||
)
|
||||
from src.domains.tools.environment.service import EnvironmentService
|
||||
|
||||
__all__ = [
|
||||
"WeatherData",
|
||||
"ForecastDay",
|
||||
"SunTimesData",
|
||||
"AirQualityData",
|
||||
"EnvironmentResponse",
|
||||
"EnvironmentService",
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Environment data schemas for Tools domain.
|
||||
|
||||
Provides Pydantic models for weather, forecast, sun times, and air quality data
|
||||
retrieved from the Qdrant volatile collection.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import Field
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class WeatherData(BaseSchema):
|
||||
"""Current weather conditions."""
|
||||
|
||||
temperature: Optional[float] = Field(
|
||||
None,
|
||||
description="Current temperature in Celsius"
|
||||
)
|
||||
feels_like: Optional[float] = Field(
|
||||
None,
|
||||
description="Feels-like temperature in Celsius"
|
||||
)
|
||||
conditions: Optional[str] = Field(
|
||||
None,
|
||||
description="Weather conditions description (e.g., 'Partly Cloudy')"
|
||||
)
|
||||
humidity: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=100,
|
||||
description="Humidity percentage"
|
||||
)
|
||||
wind_speed: Optional[float] = Field(
|
||||
None,
|
||||
description="Wind speed in km/h"
|
||||
)
|
||||
wind_direction: Optional[str] = Field(
|
||||
None,
|
||||
description="Wind direction (e.g., 'NW')"
|
||||
)
|
||||
pressure: Optional[float] = Field(
|
||||
None,
|
||||
description="Atmospheric pressure in hPa"
|
||||
)
|
||||
visibility: Optional[float] = Field(
|
||||
None,
|
||||
description="Visibility in km"
|
||||
)
|
||||
uv_index: Optional[float] = Field(
|
||||
None,
|
||||
description="UV index"
|
||||
)
|
||||
location: Optional[str] = Field(
|
||||
None,
|
||||
description="Location name"
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
description="Weather icon code or URL"
|
||||
)
|
||||
|
||||
|
||||
class ForecastDay(BaseSchema):
|
||||
"""Single day forecast data."""
|
||||
|
||||
date: str = Field(
|
||||
...,
|
||||
description="Date string (e.g., '2025-01-07')"
|
||||
)
|
||||
high: Optional[float] = Field(
|
||||
None,
|
||||
description="High temperature in Celsius"
|
||||
)
|
||||
low: Optional[float] = Field(
|
||||
None,
|
||||
description="Low temperature in Celsius"
|
||||
)
|
||||
conditions: Optional[str] = Field(
|
||||
None,
|
||||
description="Weather conditions description"
|
||||
)
|
||||
precipitation_chance: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=100,
|
||||
description="Chance of precipitation percentage"
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
None,
|
||||
description="Weather icon code or URL"
|
||||
)
|
||||
|
||||
|
||||
class SunTimesData(BaseSchema):
|
||||
"""Sunrise and sunset times."""
|
||||
|
||||
sunrise: Optional[datetime] = Field(
|
||||
None,
|
||||
description="Sunrise time"
|
||||
)
|
||||
sunset: Optional[datetime] = Field(
|
||||
None,
|
||||
description="Sunset time"
|
||||
)
|
||||
daylight_minutes: Optional[int] = Field(
|
||||
None,
|
||||
description="Total daylight duration in minutes"
|
||||
)
|
||||
solar_noon: Optional[datetime] = Field(
|
||||
None,
|
||||
description="Solar noon time"
|
||||
)
|
||||
dawn: Optional[datetime] = Field(
|
||||
None,
|
||||
description="Civil dawn time"
|
||||
)
|
||||
dusk: Optional[datetime] = Field(
|
||||
None,
|
||||
description="Civil dusk time"
|
||||
)
|
||||
|
||||
|
||||
class AirQualityData(BaseSchema):
|
||||
"""Air quality information."""
|
||||
|
||||
aqi: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Air Quality Index"
|
||||
)
|
||||
quality: Optional[str] = Field(
|
||||
None,
|
||||
description="Quality category (Good, Moderate, Unhealthy, etc.)"
|
||||
)
|
||||
pm25: Optional[float] = Field(
|
||||
None,
|
||||
description="PM2.5 concentration in microg/m3"
|
||||
)
|
||||
pm10: Optional[float] = Field(
|
||||
None,
|
||||
description="PM10 concentration in microg/m3"
|
||||
)
|
||||
o3: Optional[float] = Field(
|
||||
None,
|
||||
description="Ozone concentration in ppb"
|
||||
)
|
||||
no2: Optional[float] = Field(
|
||||
None,
|
||||
description="Nitrogen dioxide concentration in ppb"
|
||||
)
|
||||
location: Optional[str] = Field(
|
||||
None,
|
||||
description="Location name"
|
||||
)
|
||||
|
||||
|
||||
class EnvironmentResponse(BaseSchema):
|
||||
"""Combined environment data response."""
|
||||
|
||||
weather: Optional[WeatherData] = Field(
|
||||
None,
|
||||
description="Current weather conditions"
|
||||
)
|
||||
forecast: Optional[List[ForecastDay]] = Field(
|
||||
None,
|
||||
description="Multi-day weather forecast"
|
||||
)
|
||||
sun_times: Optional[SunTimesData] = Field(
|
||||
None,
|
||||
description="Sunrise/sunset times"
|
||||
)
|
||||
air_quality: Optional[AirQualityData] = Field(
|
||||
None,
|
||||
description="Air quality data (None if not available)"
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow,
|
||||
description="Timestamp when data was fetched"
|
||||
)
|
||||
user: Optional[str] = Field(
|
||||
None,
|
||||
description="User identifier used for data lookup"
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user