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:
|
except Exception:
|
||||||
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
|
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: active projects
|
||||||
projects_summary = []
|
projects_summary = []
|
||||||
try:
|
try:
|
||||||
@@ -281,6 +323,7 @@ async def _gather_internal(user_id: int) -> dict:
|
|||||||
"all_tasks_raw": all_tasks,
|
"all_tasks_raw": all_tasks,
|
||||||
"focus_tasks": focus_tasks,
|
"focus_tasks": focus_tasks,
|
||||||
"event_time_blocks": event_time_blocks,
|
"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.extend(f" - {e}" for e in internal_data["calendar_events"])
|
||||||
lines.append("")
|
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
|
# Tasks due today
|
||||||
if internal_data.get("due_today"):
|
if internal_data.get("due_today"):
|
||||||
lines.append("DUE TODAY (suggest next steps — offer to mark in progress or help prioritize):")
|
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
|
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:
|
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
|
||||||
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
|
||||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
|||||||
Reference in New Issue
Block a user