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:
@@ -18,7 +18,8 @@ class VolatileNamespace(str, Enum):
|
|||||||
Each namespace can have different default TTLs and refresh schedules.
|
Each namespace can have different default TTLs and refresh schedules.
|
||||||
"""
|
"""
|
||||||
# Real-time external data
|
# 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
|
SUN = "sun" # Sunrise, sunset, daylight duration
|
||||||
NEWS = "news" # Headlines, breaking news
|
NEWS = "news" # Headlines, breaking news
|
||||||
FINANCIAL = "financial" # Stock prices, exchange rates, crypto
|
FINANCIAL = "financial" # Stock prices, exchange rates, crypto
|
||||||
@@ -38,7 +39,8 @@ class VolatileNamespace(str, Enum):
|
|||||||
|
|
||||||
# Default TTLs per namespace (in seconds)
|
# Default TTLs per namespace (in seconds)
|
||||||
NAMESPACE_DEFAULT_TTL: Dict[str, int] = {
|
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.SUN: 86400, # 24 hours - sun times change daily
|
||||||
VolatileNamespace.NEWS: 3600, # 1 hour - news cycles
|
VolatileNamespace.NEWS: 3600, # 1 hour - news cycles
|
||||||
VolatileNamespace.FINANCIAL: 300, # 5 min - markets move fast
|
VolatileNamespace.FINANCIAL: 300, # 5 min - markets move fast
|
||||||
|
|||||||
+47
-5
@@ -233,16 +233,16 @@ async def store_volatile(
|
|||||||
async def fetch_weather(
|
async def fetch_weather(
|
||||||
city: str,
|
city: str,
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
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,
|
qdrant: QdrantDep = None,
|
||||||
ollama: OllamaDep = None,
|
ollama: OllamaDep = None,
|
||||||
api_key: str = Depends(verify_api_key)
|
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
|
Stores temperature, humidity, wind, UV index. For forecasts use /fetch/forecast.
|
||||||
and fetches weather from Open-Meteo API.
|
Called by scheduler for hourly prefetch or on-demand.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```
|
```
|
||||||
@@ -257,7 +257,49 @@ async def fetch_weather(
|
|||||||
weather_provider=weather_provider,
|
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:
|
if not result.success:
|
||||||
raise HTTPException(status_code=500, detail=result.error)
|
raise HTTPException(status_code=500, detail=result.error)
|
||||||
|
|||||||
@@ -67,12 +67,86 @@ class VolatileFetchService:
|
|||||||
self.news = news_provider
|
self.news = news_provider
|
||||||
self.financial = financial_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,
|
self,
|
||||||
user: str,
|
user: str,
|
||||||
city: str,
|
city: str,
|
||||||
days: int = 7,
|
days: int = 7,
|
||||||
ttl: int = 86400, # 24 hours
|
ttl: int = 43200, # 12 hours
|
||||||
) -> FetchResult:
|
) -> FetchResult:
|
||||||
"""
|
"""
|
||||||
Fetch weather forecast for a city and store in volatile cache.
|
Fetch weather forecast for a city and store in volatile cache.
|
||||||
@@ -81,7 +155,7 @@ class VolatileFetchService:
|
|||||||
user: User identifier
|
user: User identifier
|
||||||
city: City name (will be geocoded)
|
city: City name (will be geocoded)
|
||||||
days: Number of forecast days (1-16)
|
days: Number of forecast days (1-16)
|
||||||
ttl: Time-to-live in seconds
|
ttl: Time-to-live in seconds (default 12 hours)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
FetchResult with success status and stored record
|
FetchResult with success status and stored record
|
||||||
@@ -92,13 +166,12 @@ class VolatileFetchService:
|
|||||||
if not location:
|
if not location:
|
||||||
return FetchResult(
|
return FetchResult(
|
||||||
success=False,
|
success=False,
|
||||||
namespace="weather",
|
namespace="forecast",
|
||||||
key=city.lower(),
|
key=city.lower(),
|
||||||
error=f"Could not geocode city: {city}"
|
error=f"Could not geocode city: {city}"
|
||||||
)
|
)
|
||||||
|
|
||||||
forecast = await self.weather.get_forecast(location, days=days)
|
forecast = await self.weather.get_forecast(location, days=days)
|
||||||
current = forecast.current
|
|
||||||
|
|
||||||
# Build daily forecast array
|
# Build daily forecast array
|
||||||
daily_forecasts = []
|
daily_forecasts = []
|
||||||
@@ -116,32 +189,23 @@ class VolatileFetchService:
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Generate natural language summary
|
# Generate natural language summary
|
||||||
forecast_lines = [current.to_text()]
|
forecast_lines = [f"{city} {days}-day forecast:"]
|
||||||
for day in forecast.daily[:5]: # First 5 days
|
for day in forecast.daily:
|
||||||
forecast_lines.append(day.to_text())
|
forecast_lines.append(day.to_text())
|
||||||
text = "\n".join(forecast_lines)
|
text = "\n".join(forecast_lines)
|
||||||
|
|
||||||
# Convert to storage format
|
# Convert to storage format
|
||||||
data = {
|
data = {
|
||||||
"current": {
|
"days": days,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
"daily": daily_forecasts,
|
"daily": daily_forecasts,
|
||||||
"location": current.location,
|
"location": forecast.current.location,
|
||||||
"text": text,
|
"text": text,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Store in volatile cache
|
# Store in volatile cache
|
||||||
record = await self.volatile.store(
|
record = await self.volatile.store(
|
||||||
user=user,
|
user=user,
|
||||||
namespace=VolatileNamespace.WEATHER,
|
namespace=VolatileNamespace.FORECAST,
|
||||||
key=city.lower(),
|
key=city.lower(),
|
||||||
data=data,
|
data=data,
|
||||||
source="openmeteo",
|
source="openmeteo",
|
||||||
@@ -151,16 +215,16 @@ class VolatileFetchService:
|
|||||||
logger.info(f"Stored {days}-day forecast for {city} (user={user})")
|
logger.info(f"Stored {days}-day forecast for {city} (user={user})")
|
||||||
return FetchResult(
|
return FetchResult(
|
||||||
success=True,
|
success=True,
|
||||||
namespace="weather",
|
namespace="forecast",
|
||||||
key=city.lower(),
|
key=city.lower(),
|
||||||
record=record
|
record=record
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
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(
|
return FetchResult(
|
||||||
success=False,
|
success=False,
|
||||||
namespace="weather",
|
namespace="forecast",
|
||||||
key=city.lower(),
|
key=city.lower(),
|
||||||
error=str(e)
|
error=str(e)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class TestVolatileNamespaces:
|
|||||||
|
|
||||||
def test_weather_default_ttl(self):
|
def test_weather_default_ttl(self):
|
||||||
"""Test weather namespace default TTL."""
|
"""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):
|
def test_financial_default_ttl(self):
|
||||||
"""Test financial namespace default TTL."""
|
"""Test financial namespace default TTL."""
|
||||||
@@ -110,7 +110,7 @@ class TestVolatileNamespaces:
|
|||||||
|
|
||||||
def test_namespace_count(self):
|
def test_namespace_count(self):
|
||||||
"""Test we have the expected number of namespaces."""
|
"""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:
|
class TestVolatileListResponse:
|
||||||
@@ -274,7 +274,7 @@ class TestVolatileService:
|
|||||||
def test_get_default_ttl_known_namespace(self, volatile_service):
|
def test_get_default_ttl_known_namespace(self, volatile_service):
|
||||||
"""Test default TTL for known namespace."""
|
"""Test default TTL for known namespace."""
|
||||||
ttl = volatile_service._get_default_ttl("weather")
|
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):
|
def test_get_default_ttl_unknown_namespace(self, volatile_service):
|
||||||
"""Test default TTL for unknown namespace."""
|
"""Test default TTL for unknown namespace."""
|
||||||
@@ -366,7 +366,7 @@ class TestVolatileService:
|
|||||||
ttl=None # Not specified
|
ttl=None # Not specified
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.ttl == 1800 # Weather default
|
assert result.ttl == 3600 # Weather default (1 hour)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_empty_collection(self, volatile_service, mock_qdrant, mock_ollama):
|
async def test_search_empty_collection(self, volatile_service, mock_qdrant, mock_ollama):
|
||||||
|
|||||||
Reference in New Issue
Block a user