- 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>
737 lines
24 KiB
Python
737 lines
24 KiB
Python
"""
|
|
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,
|
|
)
|