7602bf2293
Backend: - Delete briefing services (pipeline, scheduler, conversations, profile, tools) - Delete routes/briefing.py + remove blueprint registration - Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting) - Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific) - Strip briefing scheduler hooks from app.py - Strip briefing scheduler call from routes/settings.py - Update test imports (test_rss_service, test_tz_helpers) Frontend: - Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow - Strip /briefing route + nav links (AppHeader, KnowledgeView) - Strip Settings → Briefing tab + state + functions + imports - Strip briefing-intermediate handling from ChatMessage - Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree) - Drop unused useSettingsStore from AppHeader The Android BriefingScreen lives in a separate repo and is not touched here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
400 lines
15 KiB
Python
400 lines
15 KiB
Python
"""Weather service: geocoding, Open-Meteo 7-day forecast, caching, change detection."""
|
||
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
|
||
import httpx
|
||
from sqlalchemy import select
|
||
|
||
from fabledassistant.models import async_session
|
||
from fabledassistant.models.weather_cache import WeatherCache
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||
OPEN_METEO_DAILY = (
|
||
"temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||
"precipitation_probability_max,weathercode,windspeed_10m_max"
|
||
)
|
||
OPEN_METEO_HOURLY = "precipitation_probability"
|
||
|
||
# WMO weather code → description (subset; covers the most common codes)
|
||
_WMO_CODES: dict[int, str] = {
|
||
0: "Clear sky",
|
||
1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
|
||
45: "Fog", 48: "Depositing rime fog",
|
||
51: "Drizzle: light", 53: "Drizzle: moderate", 55: "Drizzle: dense",
|
||
61: "Rain: slight", 63: "Rain: moderate", 65: "Rain: heavy",
|
||
71: "Snow: slight", 73: "Snow: moderate", 75: "Snow: heavy",
|
||
77: "Snow grains",
|
||
80: "Rain showers: slight", 81: "Rain showers: moderate", 82: "Rain showers: violent",
|
||
85: "Snow showers: slight", 86: "Snow showers: heavy",
|
||
95: "Thunderstorm",
|
||
96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail",
|
||
}
|
||
|
||
|
||
def describe_weathercode(code: int) -> str:
|
||
return _WMO_CODES.get(code, f"Unknown (code {code})")
|
||
|
||
|
||
async def get_temp_unit(user_id: int) -> str:
|
||
"""Read the user's preferred temperature unit ('C' or 'F'), default 'C'."""
|
||
from fabledassistant.services.settings import get_setting
|
||
raw = await get_setting(user_id, "temp_unit", "C")
|
||
return raw if raw in ("C", "F") else "C"
|
||
|
||
|
||
def parse_forecast(raw: dict) -> list[dict]:
|
||
"""Convert Open-Meteo daily response into a clean list of day dicts."""
|
||
daily = raw.get("daily", {})
|
||
dates = daily.get("time", [])
|
||
precip_prob = daily.get("precipitation_probability_max", [])
|
||
return [
|
||
{
|
||
"date": dates[i],
|
||
"temp_max": daily.get("temperature_2m_max", [])[i],
|
||
"temp_min": daily.get("temperature_2m_min", [])[i],
|
||
"precip_mm": daily.get("precipitation_sum", [])[i],
|
||
"precip_probability": precip_prob[i] if i < len(precip_prob) else None,
|
||
"weathercode": daily.get("weathercode", [])[i],
|
||
"description": describe_weathercode(daily.get("weathercode", [])[i]),
|
||
"windspeed_max": daily.get("windspeed_10m_max", [])[i],
|
||
}
|
||
for i in range(len(dates))
|
||
]
|
||
|
||
|
||
def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
|
||
"""Return human-readable change strings where the forecast has meaningfully shifted."""
|
||
old_by_date = {d["date"]: d for d in old_days}
|
||
changes = []
|
||
for day in new_days:
|
||
date = day["date"]
|
||
old = old_by_date.get(date)
|
||
if not old:
|
||
continue
|
||
notes = []
|
||
# Weather condition changed
|
||
if day["weathercode"] != old["weathercode"]:
|
||
notes.append(
|
||
f"conditions changed from '{describe_weathercode(old['weathercode'])}' "
|
||
f"to '{describe_weathercode(day['weathercode'])}'"
|
||
)
|
||
# Precipitation appeared or disappeared (threshold: 1mm)
|
||
was_dry = old["precip_mm"] < 1.0
|
||
now_wet = day["precip_mm"] >= 1.0
|
||
was_wet = old["precip_mm"] >= 1.0
|
||
now_dry = day["precip_mm"] < 1.0
|
||
if was_dry and now_wet:
|
||
notes.append(f"rain now expected ({day['precip_mm']:.1f}mm)")
|
||
elif was_wet and now_dry:
|
||
notes.append("rain no longer expected")
|
||
# Temperature shifted by 4°C or more
|
||
temp_shift = abs(day["temp_max"] - old["temp_max"])
|
||
if temp_shift >= 4:
|
||
direction = "warmer" if day["temp_max"] > old["temp_max"] else "colder"
|
||
notes.append(f"high temperature {direction} by {temp_shift:.0f}°C")
|
||
if notes:
|
||
changes.append(f"{date}: " + "; ".join(notes))
|
||
return changes
|
||
|
||
|
||
def _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None:
|
||
"""Build a human-readable precipitation summary from (hour, probability) pairs.
|
||
|
||
Returns None when no significant precipitation is expected.
|
||
"""
|
||
wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold]
|
||
if not wet_hours:
|
||
return None
|
||
|
||
peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1])
|
||
daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22]
|
||
if not daytime_hours:
|
||
return None
|
||
|
||
wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold]
|
||
if len(wet_daytime) >= 10:
|
||
return f"Rain likely all day (up to {peak_prob}%)"
|
||
|
||
if not wet_daytime:
|
||
return None
|
||
|
||
def _fmt_hour(h: int) -> str:
|
||
if h == 0 or h == 24:
|
||
return "12 AM"
|
||
if h == 12:
|
||
return "12 PM"
|
||
return f"{h} AM" if h < 12 else f"{h - 12} PM"
|
||
|
||
start = wet_daytime[0]
|
||
end = wet_daytime[-1]
|
||
if start == end:
|
||
return f"{peak_prob}% chance around {_fmt_hour(start)}"
|
||
return f"Rain likely {_fmt_hour(start)}–{_fmt_hour(end + 1)} (up to {peak_prob}%)"
|
||
|
||
|
||
def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]:
|
||
"""Extract (hour, probability) pairs for a specific date from cached forecast JSON."""
|
||
hourly = raw.get("hourly", {})
|
||
times = hourly.get("precipitation_probability", [])
|
||
time_labels = hourly.get("time", [])
|
||
pairs: list[tuple[int, int]] = []
|
||
prefix = date_str + "T"
|
||
for i, t in enumerate(time_labels):
|
||
if t.startswith(prefix) and i < len(times) and times[i] is not None:
|
||
hour = int(t[11:13])
|
||
pairs.append((hour, times[i]))
|
||
return pairs
|
||
|
||
|
||
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]
|
||
|
||
imperial = temp_unit == "F"
|
||
|
||
def to_temp(c: float) -> int:
|
||
return round(c * 9 / 5 + 32) if imperial else round(c)
|
||
|
||
def to_wind(kmh: float) -> int:
|
||
return round(kmh * 0.621371) if imperial else round(kmh)
|
||
|
||
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
|
||
|
||
wind_unit = "mph" if imperial else "km/h"
|
||
|
||
today_hourly = _extract_hourly_precip_for_date(raw, today_str)
|
||
today_precip_summary = _summarize_precip(today_hourly)
|
||
|
||
def _forecast_day(d: dict) -> dict:
|
||
entry: dict = {
|
||
"day": day_label(d["date"]),
|
||
"condition": d["description"],
|
||
"high": to_temp(d["temp_max"]),
|
||
"low": to_temp(d["temp_min"]),
|
||
"precip_probability": d["precip_probability"],
|
||
"precip_mm": d["precip_mm"],
|
||
"windspeed_max": to_wind(d["windspeed_max"]),
|
||
}
|
||
hourly = _extract_hourly_precip_for_date(raw, d["date"])
|
||
summary = _summarize_precip(hourly)
|
||
if summary:
|
||
entry["precip_summary"] = summary
|
||
if hourly:
|
||
peak = max(hourly, key=lambda x: x[1])
|
||
if peak[1] >= 30:
|
||
h = peak[0]
|
||
entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM")
|
||
return entry
|
||
|
||
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,
|
||
"wind_unit": wind_unit,
|
||
"precip_summary": today_precip_summary,
|
||
"forecast": [_forecast_day(d) 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:
|
||
resp = await client.get(NOMINATIM_URL, params={"q": query, "format": "json", "limit": 1})
|
||
resp.raise_for_status()
|
||
results = resp.json()
|
||
if not results:
|
||
raise ValueError(f"No geocoding result for: {query!r}")
|
||
r = results[0]
|
||
return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
|
||
|
||
|
||
async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
|
||
"""Fetch current temperature + conditions + next 3 hours precipitation.
|
||
|
||
Lightweight call — no daily forecast, no caching needed.
|
||
Returns dict with: temperature, windspeed, weathercode, description,
|
||
and precip_next_3h (list of hourly precip probabilities).
|
||
"""
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
resp = await client.get(OPEN_METEO_URL, params={
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"current_weather": "true",
|
||
"hourly": "precipitation_probability",
|
||
"forecast_days": 1,
|
||
"timezone": "auto",
|
||
})
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
current = data.get("current_weather", {})
|
||
hourly = data.get("hourly", {})
|
||
hourly_times = hourly.get("time", [])
|
||
hourly_precip = hourly.get("precipitation_probability", [])
|
||
|
||
# Find the current hour index and get next 3 hours of precip
|
||
current_time = current.get("time", "")
|
||
precip_next_3h = []
|
||
try:
|
||
idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
|
||
precip_next_3h = hourly_precip[idx:idx + 3]
|
||
except (StopIteration, IndexError):
|
||
pass
|
||
|
||
code = current.get("weathercode", 0)
|
||
return {
|
||
"temperature": current.get("temperature"),
|
||
"windspeed": current.get("windspeed"),
|
||
"weathercode": code,
|
||
"description": describe_weathercode(code),
|
||
"time": current_time,
|
||
"precip_next_3h": precip_next_3h,
|
||
}
|
||
except Exception:
|
||
logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
|
||
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, hourly precip, 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,
|
||
"hourly": OPEN_METEO_HOURLY,
|
||
"current_weather": "true",
|
||
"past_days": 1,
|
||
"timezone": "auto",
|
||
"forecast_days": 7,
|
||
})
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
async def refresh_location_cache(
|
||
user_id: int, location_key: str, location_label: str, lat: float, lon: float
|
||
) -> WeatherCache:
|
||
"""Fetch fresh forecast and update the cache row for one location."""
|
||
raw = await _fetch_open_meteo(lat, lon)
|
||
async with async_session() as session:
|
||
result = await session.execute(
|
||
select(WeatherCache).where(
|
||
WeatherCache.user_id == user_id,
|
||
WeatherCache.location_key == location_key,
|
||
)
|
||
)
|
||
cache = result.scalars().first()
|
||
if cache is None:
|
||
cache = WeatherCache(
|
||
user_id=user_id,
|
||
location_key=location_key,
|
||
location_label=location_label,
|
||
)
|
||
session.add(cache)
|
||
# Rotate: current becomes previous before overwriting
|
||
cache.previous_json = cache.forecast_json
|
||
cache.forecast_json = raw
|
||
cache.location_label = location_label
|
||
cache.fetched_at = datetime.now(timezone.utc)
|
||
await session.commit()
|
||
await session.refresh(cache)
|
||
return cache
|
||
|
||
|
||
async def get_cached_weather(user_id: int) -> list[dict]:
|
||
"""Return all cached weather entries for the user, with parsed days and change notes."""
|
||
async with async_session() as session:
|
||
result = await session.execute(
|
||
select(WeatherCache).where(WeatherCache.user_id == user_id)
|
||
)
|
||
rows = list(result.scalars().all())
|
||
|
||
out = []
|
||
for row in rows:
|
||
current_days = parse_forecast(row.forecast_json) if row.forecast_json else []
|
||
previous_days = parse_forecast(row.previous_json) if row.previous_json else []
|
||
changes = detect_changes(previous_days, current_days) if previous_days else []
|
||
out.append({
|
||
"location_key": row.location_key,
|
||
"location_label": row.location_label,
|
||
"fetched_at": row.fetched_at.isoformat(),
|
||
"days": current_days,
|
||
"changes_since_last_fetch": changes,
|
||
})
|
||
return out
|