diff --git a/src/models/volatile.py b/src/models/volatile.py index 9cf92f4..cbdb78c 100644 --- a/src/models/volatile.py +++ b/src/models/volatile.py @@ -18,7 +18,8 @@ class VolatileNamespace(str, Enum): Each namespace can have different default TTLs and refresh schedules. """ # Real-time external data - WEATHER = "weather" # Current conditions, forecasts + WEATHER = "weather" # Current conditions (temperature, humidity, wind) + FORECAST = "forecast" # Multi-day weather outlook SUN = "sun" # Sunrise, sunset, daylight duration NEWS = "news" # Headlines, breaking news FINANCIAL = "financial" # Stock prices, exchange rates, crypto @@ -38,7 +39,8 @@ class VolatileNamespace(str, Enum): # Default TTLs per namespace (in seconds) NAMESPACE_DEFAULT_TTL: Dict[str, int] = { - VolatileNamespace.WEATHER: 1800, # 30 min - weather changes slowly + VolatileNamespace.WEATHER: 3600, # 1 hour - current conditions + VolatileNamespace.FORECAST: 43200, # 12 hours - forecast stable longer VolatileNamespace.SUN: 86400, # 24 hours - sun times change daily VolatileNamespace.NEWS: 3600, # 1 hour - news cycles VolatileNamespace.FINANCIAL: 300, # 5 min - markets move fast diff --git a/src/routers/volatile.py b/src/routers/volatile.py index 4648279..ea94de3 100644 --- a/src/routers/volatile.py +++ b/src/routers/volatile.py @@ -233,16 +233,16 @@ async def store_volatile( async def fetch_weather( city: str, user: str = Query(default=DEFAULT_USER, description="User identifier"), - ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds"), + ttl: int = Query(default=3600, ge=60, le=86400, description="TTL in seconds (default 1 hour)"), qdrant: QdrantDep = None, ollama: OllamaDep = None, api_key: str = Depends(verify_api_key) ): """ - Fetch current weather for a city and store in volatile cache. + Fetch current weather conditions for a city and store in volatile cache. - Called by scheduler for prefetch or on-demand. Geocodes city name - and fetches weather from Open-Meteo API. + Stores temperature, humidity, wind, UV index. For forecasts use /fetch/forecast. + Called by scheduler for hourly prefetch or on-demand. **Example:** ``` @@ -257,7 +257,49 @@ async def fetch_weather( weather_provider=weather_provider, ) - result = await fetch_service.fetch_weather(user, city, ttl=ttl) + result = await fetch_service.fetch_current_weather(user, city, ttl=ttl) + + if not result.success: + raise HTTPException(status_code=500, detail=result.error) + + return { + "success": True, + "namespace": result.namespace, + "key": result.key, + "record": result.record, + } + + +@router.post("/fetch/forecast/{city}") +async def fetch_forecast( + city: str, + user: str = Query(default=DEFAULT_USER, description="User identifier"), + days: int = Query(default=7, ge=1, le=16, description="Forecast days (1-16)"), + ttl: int = Query(default=43200, ge=60, le=604800, description="TTL in seconds (default 12 hours)"), + qdrant: QdrantDep = None, + ollama: OllamaDep = None, + api_key: str = Depends(verify_api_key) +): + """ + Fetch weather forecast for a city and store in volatile cache. + + Stores multi-day outlook with highs/lows, precipitation, UV. + For current conditions use /fetch/weather. + + **Example:** + ``` + POST /volatile/fetch/forecast/amsterdam?user=jpmschweitzer&days=7 + ``` + """ + volatile_service = get_volatile_service(qdrant, ollama) + weather_provider = get_weather_provider() + + fetch_service = VolatileFetchService( + volatile_service=volatile_service, + weather_provider=weather_provider, + ) + + result = await fetch_service.fetch_forecast(user, city, days=days, ttl=ttl) if not result.success: raise HTTPException(status_code=500, detail=result.error) diff --git a/src/services/volatile_fetch_service.py b/src/services/volatile_fetch_service.py index 5484d1a..5c6d0bf 100644 --- a/src/services/volatile_fetch_service.py +++ b/src/services/volatile_fetch_service.py @@ -67,12 +67,86 @@ class VolatileFetchService: self.news = news_provider self.financial = financial_provider - async def fetch_weather( + 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 = 86400, # 24 hours + ttl: int = 43200, # 12 hours ) -> FetchResult: """ Fetch weather forecast for a city and store in volatile cache. @@ -81,7 +155,7 @@ class VolatileFetchService: user: User identifier city: City name (will be geocoded) days: Number of forecast days (1-16) - ttl: Time-to-live in seconds + ttl: Time-to-live in seconds (default 12 hours) Returns: FetchResult with success status and stored record @@ -92,13 +166,12 @@ class VolatileFetchService: if not location: return FetchResult( success=False, - namespace="weather", + namespace="forecast", key=city.lower(), error=f"Could not geocode city: {city}" ) forecast = await self.weather.get_forecast(location, days=days) - current = forecast.current # Build daily forecast array daily_forecasts = [] @@ -116,32 +189,23 @@ class VolatileFetchService: }) # Generate natural language summary - forecast_lines = [current.to_text()] - for day in forecast.daily[:5]: # First 5 days + 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 = { - "current": { - "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, - }, + "days": days, "daily": daily_forecasts, - "location": current.location, + "location": forecast.current.location, "text": text, } # Store in volatile cache record = await self.volatile.store( user=user, - namespace=VolatileNamespace.WEATHER, + namespace=VolatileNamespace.FORECAST, key=city.lower(), data=data, source="openmeteo", @@ -151,16 +215,16 @@ class VolatileFetchService: logger.info(f"Stored {days}-day forecast for {city} (user={user})") return FetchResult( success=True, - namespace="weather", + namespace="forecast", key=city.lower(), record=record ) except Exception as e: - logger.error(f"Failed to fetch weather for {city}: {e}") + logger.error(f"Failed to fetch forecast for {city}: {e}") return FetchResult( success=False, - namespace="weather", + namespace="forecast", key=city.lower(), error=str(e) ) diff --git a/tests/test_volatile.py b/tests/test_volatile.py index 4accdfa..73bdf37 100644 --- a/tests/test_volatile.py +++ b/tests/test_volatile.py @@ -98,7 +98,7 @@ class TestVolatileNamespaces: def test_weather_default_ttl(self): """Test weather namespace default TTL.""" - assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.WEATHER] == 1800 # 30 min + assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.WEATHER] == 3600 # 1 hour (current conditions) def test_financial_default_ttl(self): """Test financial namespace default TTL.""" @@ -110,7 +110,7 @@ class TestVolatileNamespaces: def test_namespace_count(self): """Test we have the expected number of namespaces.""" - assert len(VolatileNamespace) == 12 # Including SUN for sunrise/sunset + assert len(VolatileNamespace) == 13 # Including SUN, FORECAST class TestVolatileListResponse: @@ -274,7 +274,7 @@ class TestVolatileService: def test_get_default_ttl_known_namespace(self, volatile_service): """Test default TTL for known namespace.""" ttl = volatile_service._get_default_ttl("weather") - assert ttl == 1800 # Weather namespace default + assert ttl == 3600 # Weather namespace default (1 hour) def test_get_default_ttl_unknown_namespace(self, volatile_service): """Test default TTL for unknown namespace.""" @@ -366,7 +366,7 @@ class TestVolatileService: ttl=None # Not specified ) - assert result.ttl == 1800 # Weather default + assert result.ttl == 3600 # Weather default (1 hour) @pytest.mark.asyncio async def test_search_empty_collection(self, volatile_service, mock_qdrant, mock_ollama):