feat: add external API providers and central settings database
- Add central settings database client (system_settings on postgres-shared) - User-scoped settings with global fallback - API config storage with enabled/disabled toggle - Per-source category filtering for news - Add modular external API providers in src/apis/: - OpenMeteoProvider: weather with geocoding (free, no key) - NOSProvider: Dutch news RSS feeds - BBCProvider: English news RSS feeds - AggregatedNewsProvider: merges sources with category filtering - AlphaVantageProvider: financial quotes (API key from settings DB) - Add provider dependencies and lifecycle management - Add requirements-dev.txt with pip-audit for security auditing - Add MEMORY_REMEMBER_PLAN.md documenting volatile/document memory architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Base classes and standardized response models for external APIs.
|
||||
|
||||
All provider implementations should return these standard models
|
||||
to ensure interoperability when swapping providers.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Weather Models
|
||||
# =============================================================================
|
||||
|
||||
class WeatherCondition(Enum):
|
||||
"""Standardized weather conditions across providers."""
|
||||
CLEAR = "clear"
|
||||
PARTLY_CLOUDY = "partly_cloudy"
|
||||
CLOUDY = "cloudy"
|
||||
OVERCAST = "overcast"
|
||||
FOG = "fog"
|
||||
DRIZZLE = "drizzle"
|
||||
RAIN = "rain"
|
||||
HEAVY_RAIN = "heavy_rain"
|
||||
SNOW = "snow"
|
||||
HEAVY_SNOW = "heavy_snow"
|
||||
THUNDERSTORM = "thunderstorm"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentWeather:
|
||||
"""Standardized current weather response."""
|
||||
temperature: float # Celsius
|
||||
feels_like: Optional[float] # Celsius
|
||||
humidity: int # Percentage 0-100
|
||||
wind_speed: float # km/h
|
||||
wind_direction: Optional[int] # Degrees 0-360
|
||||
condition: WeatherCondition
|
||||
condition_text: str # Human-readable description
|
||||
timestamp: datetime
|
||||
location: str # City/location name
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language description."""
|
||||
return (
|
||||
f"Currently {self.temperature:.1f}°C "
|
||||
f"({self.condition_text}) in {self.location}. "
|
||||
f"Humidity {self.humidity}%, wind {self.wind_speed:.0f} km/h."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayForecast:
|
||||
"""Standardized daily forecast."""
|
||||
date: datetime
|
||||
temp_high: float # Celsius
|
||||
temp_low: float # Celsius
|
||||
condition: WeatherCondition
|
||||
condition_text: str
|
||||
precipitation_chance: Optional[int] # Percentage 0-100
|
||||
precipitation_mm: Optional[float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeatherForecast:
|
||||
"""Standardized forecast response."""
|
||||
location: str
|
||||
current: CurrentWeather
|
||||
daily: list[DayForecast] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoLocation:
|
||||
"""Geocoding result."""
|
||||
name: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
country: Optional[str] = None
|
||||
admin_area: Optional[str] = None # State/province
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# News Models
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class NewsItem:
|
||||
"""Standardized news article/item."""
|
||||
title: str
|
||||
description: Optional[str]
|
||||
url: str
|
||||
published: Optional[datetime]
|
||||
source: str # e.g., "nos", "bbc"
|
||||
category: Optional[str] = None # e.g., "tech", "world"
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewsFeed:
|
||||
"""Standardized news feed response."""
|
||||
source: str
|
||||
category: str
|
||||
items: list[NewsItem] = field(default_factory=list)
|
||||
fetched_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language summary of headlines."""
|
||||
if not self.items:
|
||||
return f"No news available from {self.source}."
|
||||
|
||||
headlines = [f"- {item.title}" for item in self.items[:5]]
|
||||
return f"Headlines from {self.source} ({self.category}):\n" + "\n".join(headlines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Financial Models
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class StockQuote:
|
||||
"""Standardized stock/crypto quote."""
|
||||
symbol: str
|
||||
name: Optional[str]
|
||||
price: float
|
||||
currency: str # e.g., "USD", "EUR"
|
||||
change: Optional[float] # Absolute change
|
||||
change_percent: Optional[float] # Percentage change
|
||||
timestamp: datetime
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language description."""
|
||||
change_str = ""
|
||||
if self.change is not None and self.change_percent is not None:
|
||||
direction = "up" if self.change >= 0 else "down"
|
||||
change_str = f", {direction} {abs(self.change_percent):.2f}%"
|
||||
return f"{self.symbol}: {self.price:.2f} {self.currency}{change_str}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Interfaces
|
||||
# =============================================================================
|
||||
|
||||
class WeatherProvider(ABC):
|
||||
"""Abstract base class for weather API providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def geocode(self, city: str) -> Optional[GeoLocation]:
|
||||
"""Convert city name to coordinates."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_current(self, location: GeoLocation) -> CurrentWeather:
|
||||
"""Get current weather for a location."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_forecast(self, location: GeoLocation, days: int = 7) -> WeatherForecast:
|
||||
"""Get weather forecast for a location."""
|
||||
pass
|
||||
|
||||
async def get_weather_for_city(self, city: str) -> CurrentWeather:
|
||||
"""Convenience method: geocode and get current weather."""
|
||||
location = await self.geocode(city)
|
||||
if not location:
|
||||
raise ValueError(f"Could not geocode city: {city}")
|
||||
return await self.get_current(location)
|
||||
|
||||
|
||||
class NewsProvider(ABC):
|
||||
"""Abstract base class for news API providers."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def source_name(self) -> str:
|
||||
"""Provider name (e.g., 'nos', 'bbc')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def available_categories(self) -> list[str]:
|
||||
"""List of available category keys."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_feed(self, category: str, limit: int = 10) -> NewsFeed:
|
||||
"""Get news feed for a category."""
|
||||
pass
|
||||
|
||||
async def get_headlines(self, categories: list[str], limit: int = 5) -> list[NewsFeed]:
|
||||
"""Get headlines from multiple categories."""
|
||||
feeds = []
|
||||
for cat in categories:
|
||||
if cat in self.available_categories:
|
||||
feed = await self.get_feed(cat, limit)
|
||||
feeds.append(feed)
|
||||
return feeds
|
||||
|
||||
|
||||
class FinancialProvider(ABC):
|
||||
"""Abstract base class for financial API providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_quote(self, symbol: str) -> Optional[StockQuote]:
|
||||
"""Get current quote for a stock/crypto symbol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_quotes(self, symbols: list[str]) -> list[StockQuote]:
|
||||
"""Get quotes for multiple symbols."""
|
||||
pass
|
||||
Reference in New Issue
Block a user