feat(briefing): add past_days/current_weather to Open-Meteo; add parse_weather_card_data()

Also adds get_cached_weather_rows() for parallel gather in briefing pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 10:32:59 -04:00
parent e44eb185d5
commit 9e1615bd32
2 changed files with 122 additions and 1 deletions
+73 -1
View File
@@ -91,6 +91,76 @@ def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
return changes
def parse_weather_card_data(
cache_row,
temp_unit: str = "C",
) -> dict | None:
"""
Parse a WeatherCache row into the metadata.weather card schema.
Returns None if the cache is stale (older than 24 hours) or unavailable.
"""
from datetime import date, timedelta
if cache_row is None or cache_row.fetched_at is None:
return None
age_seconds = (datetime.now(timezone.utc) - cache_row.fetched_at).total_seconds()
if age_seconds > 86400:
return None
raw = cache_row.forecast_json or {}
current_weather = raw.get("current_weather", {})
days = parse_forecast(raw)
today_str = date.today().isoformat()
yesterday_str = (date.today() - timedelta(days=1)).isoformat()
today_day = next((d for d in days if d["date"] == today_str), None)
yesterday_day = next((d for d in days if d["date"] == yesterday_str), None)
future_days = [d for d in days if d["date"] > today_str][:5]
def to_temp(c: float) -> int:
if temp_unit == "F":
return round(c * 9 / 5 + 32)
return round(c)
def day_label(date_str: str) -> str:
from datetime import date as _date
try:
return _date.fromisoformat(date_str).strftime("%a")
except ValueError:
return date_str
return {
"location": getattr(cache_row, "location_label", ""),
"fetched_at": cache_row.fetched_at.isoformat(),
"current_temp": to_temp(current_weather.get("temperature", 0)),
"condition": describe_weathercode(current_weather.get("weathercode", 0)),
"today_high": to_temp(today_day["temp_max"]) if today_day else None,
"today_low": to_temp(today_day["temp_min"]) if today_day else None,
"yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None,
"yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
"forecast": [
{
"day": day_label(d["date"]),
"condition": d["description"],
"high": to_temp(d["temp_max"]),
"low": to_temp(d["temp_min"]),
}
for d in future_days
],
}
async def get_cached_weather_rows(user_id: int) -> list:
"""Return raw WeatherCache ORM rows for a user (for card parsing)."""
async with async_session() as session:
result = await session.execute(
select(WeatherCache).where(WeatherCache.user_id == user_id)
)
return list(result.scalars().all())
async def geocode(query: str) -> tuple[float, float, str]:
"""Return (lat, lon, display_name) for a place name using Nominatim."""
async with httpx.AsyncClient(timeout=10.0, headers={"User-Agent": "FabledAssistant/1.0"}) as client:
@@ -104,12 +174,14 @@ async def geocode(query: str) -> tuple[float, float, str]:
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo."""
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"daily": OPEN_METEO_DAILY,
"current_weather": "true",
"past_days": 1,
"timezone": "auto",
"forecast_days": 7,
})