feat(briefing): weather × calendar cross-reference — rain warnings for outdoor events
This commit is contained in:
@@ -217,6 +217,48 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
except Exception:
|
||||
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
|
||||
|
||||
# Weather × calendar cross-reference
|
||||
weather_warnings: list[str] = []
|
||||
outdoor_keywords = {"park", "field", "patio", "garden", "outdoor", "trail", "beach",
|
||||
"pool", "stadium", "yard", "lawn", "hike", "walk", "run", "bike",
|
||||
"soccer", "baseball", "football", "tennis", "golf", "playground"}
|
||||
try:
|
||||
import json as _json
|
||||
raw_config = await get_setting(user_id, "briefing_config", "{}")
|
||||
config = _json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
|
||||
locations = config.get("locations", {})
|
||||
loc = locations.get("home") or locations.get("work")
|
||||
if loc and loc.get("lat") and loc.get("lon") and internal_events:
|
||||
from fabledassistant.services.weather import fetch_hourly_precip
|
||||
hourly_precip = await fetch_hourly_precip(loc["lat"], loc["lon"])
|
||||
if hourly_precip:
|
||||
for e in internal_events:
|
||||
if e.get("all_day"):
|
||||
continue
|
||||
# Check if event title or location contains outdoor keywords
|
||||
event_text = f"{e.get('title', '')} {e.get('location', '')}".lower()
|
||||
if not any(kw in event_text for kw in outdoor_keywords):
|
||||
continue
|
||||
# Get the event's start hour
|
||||
start = e.get("start_dt")
|
||||
if not start:
|
||||
continue
|
||||
from datetime import datetime as _dt
|
||||
if isinstance(start, str):
|
||||
start = _dt.fromisoformat(start)
|
||||
local_start = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
|
||||
# Check precip probability around the event time (hour before through event)
|
||||
hour_key = local_start.strftime("%Y-%m-%dT%H:00")
|
||||
precip = hourly_precip.get(hour_key, 0)
|
||||
if precip >= 30:
|
||||
time_str = local_start.strftime("%-I:%M %p")
|
||||
weather_warnings.append(
|
||||
f"Rain likely ({precip}%) around {time_str} when '{e.get('title', 'Event')}' "
|
||||
f"is scheduled — consider an umbrella or backup plan"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Weather cross-reference failed", exc_info=True)
|
||||
|
||||
# Projects: active projects
|
||||
projects_summary = []
|
||||
try:
|
||||
@@ -281,6 +323,7 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
"all_tasks_raw": all_tasks,
|
||||
"focus_tasks": focus_tasks,
|
||||
"event_time_blocks": event_time_blocks,
|
||||
"weather_warnings": weather_warnings,
|
||||
}
|
||||
|
||||
|
||||
@@ -368,6 +411,12 @@ def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, te
|
||||
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
|
||||
lines.append("")
|
||||
|
||||
# Weather warnings for outdoor events
|
||||
if internal_data.get("weather_warnings"):
|
||||
lines.append("WEATHER ALERTS (mention these naturally when discussing affected events):")
|
||||
lines.extend(f" ⚠ {w}" for w in internal_data["weather_warnings"])
|
||||
lines.append("")
|
||||
|
||||
# Tasks due today
|
||||
if internal_data.get("due_today"):
|
||||
lines.append("DUE TODAY (suggest next steps — offer to mark in progress or help prioritize):")
|
||||
|
||||
@@ -232,6 +232,32 @@ async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
|
||||
"""Fetch today's hourly precipitation probabilities.
|
||||
|
||||
Returns a dict of ISO hour string → probability percentage, e.g.:
|
||||
{"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...}
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(OPEN_METEO_URL, params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"hourly": "precipitation_probability",
|
||||
"forecast_days": 1,
|
||||
"timezone": "auto",
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
hourly = data.get("hourly", {})
|
||||
times = hourly.get("time", [])
|
||||
probs = hourly.get("precipitation_probability", [])
|
||||
return {t: p for t, p in zip(times, probs) if p is not None}
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
|
||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
|
||||
Reference in New Issue
Block a user