""" Volatile Fetch service for Library Desk. 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, field from src.apis import ( OpenMeteoProvider, AggregatedNewsProvider, AlphaVantageProvider, CurrentWeather, WeatherForecast, SunTimes, AirQuality, NewsFeed, StockQuote, ) from src.services.volatile_service import VolatileCacheService from src.models.volatile import VolatileRecordResponse, VolatileNamespace logger = logging.getLogger(__name__) @dataclass class FetchResult: """Result of a volatile fetch operation.""" success: bool namespace: str key: str record: Optional[VolatileRecordResponse] = None 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. Supports: - Weather: Current conditions and forecast via Open-Meteo - News: Headlines from configured sources (NOS, BBC) - Financial: Stock/crypto quotes via Alpha Vantage """ def __init__( self, volatile_service: VolatileCacheService, weather_provider: OpenMeteoProvider, news_provider: Optional[AggregatedNewsProvider] = None, financial_provider: Optional[AlphaVantageProvider] = None, ): """ Initialize volatile fetch service. Args: volatile_service: Service for volatile cache storage weather_provider: Open-Meteo weather provider news_provider: Aggregated news provider (optional) financial_provider: Alpha Vantage provider (optional) """ self.volatile = volatile_service self.weather = weather_provider self.news = news_provider self.financial = financial_provider async def fetch_current_weather( self, user: str, city: str, ttl: int = 3600, # 1 hour ) -> FetchResult: """ Fetch current weather conditions for a city and store in volatile cache. Args: user: User identifier city: City name (will be geocoded) ttl: Time-to-live in seconds (default 1 hour) Returns: FetchResult with success status and stored record """ try: # Geocode city and get current conditions location = await self.weather.geocode(city) if not location: return FetchResult( success=False, namespace="weather", key=city.lower(), error=f"Could not geocode city: {city}" ) current = await self.weather.get_current(location) # Generate natural language summary text = current.to_text() # Convert to storage format 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, } # Store in volatile cache record = await self.volatile.store( user=user, namespace=VolatileNamespace.WEATHER, key=city.lower(), data=data, source="openmeteo", ttl=ttl, ) logger.info(f"Stored current weather for {city} (user={user})") return FetchResult( success=True, namespace="weather", key=city.lower(), record=record ) except Exception as e: logger.error(f"Failed to fetch current weather for {city}: {e}") return FetchResult( success=False, namespace="weather", key=city.lower(), error=str(e) ) async def fetch_forecast( self, user: str, city: str, days: int = 7, ttl: int = 43200, # 12 hours ) -> FetchResult: """ Fetch weather forecast for a city and store in volatile cache. Args: user: User identifier city: City name (will be geocoded) days: Number of forecast days (1-16) ttl: Time-to-live in seconds (default 12 hours) Returns: FetchResult with success status and stored record """ try: # Geocode city and get forecast location = await self.weather.geocode(city) if not location: return FetchResult( success=False, namespace="forecast", key=city.lower(), error=f"Could not geocode city: {city}" ) forecast = await self.weather.get_forecast(location, days=days) # Build daily forecast array daily_forecasts = [] for day in forecast.daily: daily_forecasts.append({ "date": day.date.isoformat(), "day_name": day.date.strftime("%A"), "temp_high": day.temp_high, "temp_low": day.temp_low, "conditions": day.condition_text, "condition_code": day.condition.value, "precipitation_chance": day.precipitation_chance, "precipitation_mm": day.precipitation_mm, "uv_index_max": day.uv_index_max, }) # Generate natural language summary forecast_lines = [f"{city} {days}-day forecast:"] for day in forecast.daily: forecast_lines.append(day.to_text()) text = "\n".join(forecast_lines) # Convert to storage format data = { "days": days, "daily": daily_forecasts, "location": forecast.current.location, "text": text, } # Store in volatile cache record = await self.volatile.store( user=user, namespace=VolatileNamespace.FORECAST, key=city.lower(), data=data, source="openmeteo", ttl=ttl, ) logger.info(f"Stored {days}-day forecast for {city} (user={user})") return FetchResult( success=True, namespace="forecast", key=city.lower(), record=record ) except Exception as e: logger.error(f"Failed to fetch forecast for {city}: {e}") return FetchResult( success=False, namespace="forecast", key=city.lower(), error=str(e) ) async def fetch_news( self, user: str, category: str = "general", limit: int = 10, ttl: int = 7200, # 2 hours ) -> FetchResult: """ Fetch news headlines and store in volatile cache. Args: user: User identifier category: News category (general, tech, world, etc.) limit: Maximum headlines to fetch ttl: Time-to-live in seconds Returns: FetchResult with success status and stored record """ if not self.news: return FetchResult( success=False, namespace="news", key=category, error="News provider not configured" ) try: feed = await self.news.get_feed(category, limit=limit) # Convert to storage format headlines = [] for item in feed.items: headlines.append({ "title": item.title, "description": item.description, "url": item.url, "source": item.source, "published": item.published.isoformat() if item.published else None, }) data = { "category": category, "headlines": headlines, "count": len(headlines), "sources": list(set(h["source"] for h in headlines)), "text": feed.to_text(), } # Store in volatile cache record = await self.volatile.store( user=user, namespace=VolatileNamespace.NEWS, key=category, data=data, source="aggregated", ttl=ttl, ) logger.info(f"Stored {len(headlines)} headlines for {category} (user={user})") return FetchResult( success=True, namespace="news", key=category, record=record ) except Exception as e: logger.error(f"Failed to fetch news for {category}: {e}") return FetchResult( success=False, namespace="news", key=category, error=str(e) ) async def fetch_stock( self, user: str, symbol: str, ttl: int = 300, # 5 minutes ) -> FetchResult: """ Fetch stock quote and store in volatile cache. Args: user: User identifier symbol: Stock ticker symbol (e.g., "AAPL") ttl: Time-to-live in seconds Returns: FetchResult with success status and stored record """ if not self.financial: return FetchResult( success=False, namespace="financial", key=symbol.lower(), error="Financial provider not configured" ) try: quote = await self.financial.get_quote(symbol) if not quote: return FetchResult( success=False, namespace="financial", key=symbol.lower(), error=f"No quote found for symbol: {symbol}" ) # Convert to storage format data = { "symbol": quote.symbol, "name": quote.name, "price": quote.price, "currency": quote.currency, "change": quote.change, "change_percent": quote.change_percent, "text": quote.to_text(), } # Store in volatile cache record = await self.volatile.store( user=user, namespace=VolatileNamespace.FINANCIAL, key=symbol.lower(), data=data, source="alphavantage", ttl=ttl, ) logger.info(f"Stored quote for {symbol} (user={user})") return FetchResult( success=True, namespace="financial", key=symbol.lower(), record=record ) except Exception as e: logger.error(f"Failed to fetch quote for {symbol}: {e}") return FetchResult( success=False, namespace="financial", key=symbol.lower(), error=str(e) ) async def fetch_crypto( self, user: str, symbol: str, market: str = "USD", ttl: int = 300, # 5 minutes ) -> FetchResult: """ Fetch cryptocurrency quote and store in volatile cache. Args: user: User identifier symbol: Crypto symbol (e.g., "BTC", "ETH") market: Market currency (default: USD) ttl: Time-to-live in seconds Returns: FetchResult with success status and stored record """ if not self.financial: return FetchResult( success=False, namespace="financial", key=f"{symbol.lower()}_{market.lower()}", error="Financial provider not configured" ) try: quote = await self.financial.get_crypto_quote(symbol, market) if not quote: return FetchResult( success=False, namespace="financial", key=f"{symbol.lower()}_{market.lower()}", error=f"No quote found for crypto: {symbol}/{market}" ) key = f"{symbol.lower()}_{market.lower()}" # Convert to storage format data = { "symbol": quote.symbol, "name": quote.name, "price": quote.price, "currency": quote.currency, "text": quote.to_text(), } # Store in volatile cache record = await self.volatile.store( user=user, namespace=VolatileNamespace.FINANCIAL, key=key, data=data, source="alphavantage", ttl=ttl, ) logger.info(f"Stored crypto quote for {symbol}/{market} (user={user})") return FetchResult( success=True, namespace="financial", key=key, record=record ) except Exception as e: logger.error(f"Failed to fetch crypto quote for {symbol}: {e}") return FetchResult( success=False, namespace="financial", key=f"{symbol.lower()}_{market.lower()}", error=str(e) ) async def fetch_sun_times( self, user: str, city: str, ttl: int = 86400, # 24 hours ) -> FetchResult: """ Fetch sunrise/sunset times for a city and store in volatile cache. Args: user: User identifier city: City name (will be geocoded) ttl: Time-to-live in seconds Returns: FetchResult with success status and stored record """ try: # Geocode city and get sun times location = await self.weather.geocode(city) if not location: return FetchResult( success=False, namespace="sun", key=city.lower(), error=f"Could not geocode city: {city}" ) sun_times = await self.weather.get_sun_times(location) # Convert to storage format data = { "location": sun_times.location, "date": sun_times.date.isoformat(), "sunrise": sun_times.sunrise.strftime("%H:%M"), "sunset": sun_times.sunset.strftime("%H:%M"), "sunrise_iso": sun_times.sunrise.isoformat(), "sunset_iso": sun_times.sunset.isoformat(), "daylight_duration_seconds": sun_times.daylight_duration, "daylight_hours": sun_times.daylight_duration / 3600, "text": sun_times.to_text(), } # Store in volatile cache record = await self.volatile.store( user=user, namespace=VolatileNamespace.SUN, key=city.lower(), data=data, source="openmeteo", ttl=ttl, ) logger.info(f"Stored sun times for {city} (user={user})") return FetchResult( success=True, namespace="sun", key=city.lower(), record=record ) except Exception as e: logger.error(f"Failed to fetch sun times for {city}: {e}") return FetchResult( success=False, namespace="sun", key=city.lower(), error=str(e) ) async def fetch_air_quality( self, user: str, city: str, ttl: int = 3600, # 1 hour ) -> FetchResult: """ Fetch air quality data for a city and store in volatile cache. Args: user: User identifier city: City name (will be geocoded) ttl: Time-to-live in seconds Returns: FetchResult with success status and stored record """ try: # Geocode city and get air quality location = await self.weather.geocode(city) if not location: return FetchResult( success=False, namespace="air_quality", key=city.lower(), error=f"Could not geocode city: {city}" ) air_quality = await self.weather.get_air_quality(location) # Convert to storage format 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(), } # Store in volatile cache record = await self.volatile.store( user=user, namespace=VolatileNamespace.AIR_QUALITY, key=city.lower(), data=data, source="openmeteo", ttl=ttl, ) logger.info(f"Stored air quality for {city} (user={user})") return FetchResult( success=True, namespace="air_quality", key=city.lower(), record=record ) except Exception as e: logger.error(f"Failed to fetch air quality for {city}: {e}") return FetchResult( success=False, namespace="air_quality", 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, )