feat: add combined environment endpoint for concurrent weather + air quality fetch
- POST /volatile/fetch/environment/{city} fetches both in parallel
- Single geocode lookup shared between API calls
- Uses asyncio.gather() for concurrent external requests
- Fix scheduler executor name (rest_api → rest_api_executor)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,10 @@ Orchestrates fetching data from external APIs and storing in volatile cache.
|
||||
Called by scheduler for prefetch or by HybridRAG for reactive caching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.apis import (
|
||||
OpenMeteoProvider,
|
||||
@@ -36,6 +37,16 @@ class FetchResult:
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvironmentFetchResult:
|
||||
"""Result of combined environment fetch (weather + air quality)."""
|
||||
success: bool
|
||||
key: str
|
||||
weather: Optional[FetchResult] = None
|
||||
air_quality: Optional[FetchResult] = None
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class VolatileFetchService:
|
||||
"""
|
||||
Service to fetch external data and store in volatile cache.
|
||||
@@ -596,3 +607,130 @@ class VolatileFetchService:
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_environment(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
weather_ttl: int = 3600,
|
||||
air_quality_ttl: int = 3600,
|
||||
) -> EnvironmentFetchResult:
|
||||
"""
|
||||
Fetch weather and air quality concurrently for a city.
|
||||
|
||||
Performs a single geocode lookup and fetches both weather and air quality
|
||||
data in parallel, storing both in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded once)
|
||||
weather_ttl: TTL for weather data (default 1 hour)
|
||||
air_quality_ttl: TTL for air quality data (default 1 hour)
|
||||
|
||||
Returns:
|
||||
EnvironmentFetchResult with both weather and air quality results
|
||||
"""
|
||||
errors: list[str] = []
|
||||
key = city.lower()
|
||||
|
||||
# Single geocode lookup (shared by both fetches)
|
||||
try:
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return EnvironmentFetchResult(
|
||||
success=False,
|
||||
key=key,
|
||||
errors=[f"Could not geocode city: {city}"]
|
||||
)
|
||||
except Exception as e:
|
||||
return EnvironmentFetchResult(
|
||||
success=False,
|
||||
key=key,
|
||||
errors=[f"Geocoding failed: {e}"]
|
||||
)
|
||||
|
||||
# Fetch weather and air quality concurrently
|
||||
async def fetch_weather_data() -> FetchResult:
|
||||
try:
|
||||
current = await self.weather.get_current(location)
|
||||
text = current.to_text()
|
||||
data = {
|
||||
"temperature": current.temperature,
|
||||
"feels_like": current.feels_like,
|
||||
"humidity": current.humidity,
|
||||
"wind_speed": current.wind_speed,
|
||||
"wind_direction": current.wind_direction,
|
||||
"conditions": current.condition_text,
|
||||
"condition_code": current.condition.value,
|
||||
"uv_index": current.uv_index,
|
||||
"location": current.location,
|
||||
"text": text,
|
||||
}
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.WEATHER,
|
||||
key=key,
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=weather_ttl,
|
||||
)
|
||||
return FetchResult(success=True, namespace="weather", key=key, record=record)
|
||||
except Exception as e:
|
||||
return FetchResult(success=False, namespace="weather", key=key, error=str(e))
|
||||
|
||||
async def fetch_air_quality_data() -> FetchResult:
|
||||
try:
|
||||
air_quality = await self.weather.get_air_quality(location)
|
||||
data = {
|
||||
"location": air_quality.location,
|
||||
"aqi_european": air_quality.aqi_european,
|
||||
"aqi_us": air_quality.aqi_us,
|
||||
"pm2_5": air_quality.pm2_5,
|
||||
"pm10": air_quality.pm10,
|
||||
"ozone": air_quality.ozone,
|
||||
"nitrogen_dioxide": air_quality.nitrogen_dioxide,
|
||||
"sulphur_dioxide": air_quality.sulphur_dioxide,
|
||||
"carbon_monoxide": air_quality.carbon_monoxide,
|
||||
"pollen_grass": air_quality.pollen_grass,
|
||||
"pollen_birch": air_quality.pollen_birch,
|
||||
"pollen_alder": air_quality.pollen_alder,
|
||||
"text": air_quality.to_text(),
|
||||
}
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.AIR_QUALITY,
|
||||
key=key,
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=air_quality_ttl,
|
||||
)
|
||||
return FetchResult(success=True, namespace="air_quality", key=key, record=record)
|
||||
except Exception as e:
|
||||
return FetchResult(success=False, namespace="air_quality", key=key, error=str(e))
|
||||
|
||||
# Run both fetches concurrently
|
||||
weather_result, air_quality_result = await asyncio.gather(
|
||||
fetch_weather_data(),
|
||||
fetch_air_quality_data(),
|
||||
)
|
||||
|
||||
# Collect any errors
|
||||
if not weather_result.success:
|
||||
errors.append(f"Weather: {weather_result.error}")
|
||||
if not air_quality_result.success:
|
||||
errors.append(f"Air quality: {air_quality_result.error}")
|
||||
|
||||
success = weather_result.success or air_quality_result.success
|
||||
logger.info(
|
||||
f"Environment fetch for {city} (user={user}): "
|
||||
f"weather={'ok' if weather_result.success else 'failed'}, "
|
||||
f"air_quality={'ok' if air_quality_result.success else 'failed'}"
|
||||
)
|
||||
|
||||
return EnvironmentFetchResult(
|
||||
success=success,
|
||||
key=key,
|
||||
weather=weather_result,
|
||||
air_quality=air_quality_result,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user