feat: separate current weather from forecast into distinct namespaces

- Add FORECAST namespace for multi-day outlook (12hr TTL)
- WEATHER namespace now stores only current conditions (1hr TTL)
- Split fetch_weather into fetch_current_weather + fetch_forecast
- Add POST /volatile/fetch/forecast/{city} endpoint
- Different update frequencies for efficient caching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 12:44:16 +01:00
co-authored by Claude Opus 4.5
parent 68eb1add3d
commit 46b9bcd7a0
4 changed files with 141 additions and 33 deletions
+4 -2
View File
@@ -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
+47 -5
View File
@@ -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)
+86 -22
View File
@@ -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)
)
+4 -4
View File
@@ -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):