From 4558dd578ae4d817318a2be5943537d410dc8aed Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 8 Apr 2026 22:03:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(briefing):=20weather=20=C3=97=20calendar?= =?UTF-8?q?=20cross-reference=20=E2=80=94=20rain=20warnings=20for=20outdoo?= =?UTF-8?q?r=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/briefing_pipeline.py | 49 +++++++++++++++++++ src/fabledassistant/services/weather.py | 26 ++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index 19f3be4..65b20d2 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -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):") diff --git a/src/fabledassistant/services/weather.py b/src/fabledassistant/services/weather.py index 3d6c4ac..77aa1a4 100644 --- a/src/fabledassistant/services/weather.py +++ b/src/fabledassistant/services/weather.py @@ -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: