ce2d76447c
Split 2566-line tools.py into a tools/ package with @tool decorator registration. Each tool's schema, metadata, and implementation live together. Briefing eligibility is now a briefing=True flag instead of a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG) uses requires= metadata. Public API (get_tools_for_user, execute_tool) unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
5.0 KiB
Python
123 lines
5.0 KiB
Python
"""Weather tool."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json as _wx_json
|
||
import logging
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from fabledassistant.services.tools._registry import tool
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@tool(
|
||
name="get_weather",
|
||
description=(
|
||
"Get the weather forecast. By default returns today only — use days=7 when the user "
|
||
"explicitly asks for a multi-day forecast or 'this week'. "
|
||
"Pass a city/region name to look up any location, or omit to get the user's configured home/work locations."
|
||
),
|
||
parameters={
|
||
"location": {"type": "string", "description": "City/region to look up, or 'home'/'work' for saved locations. Omit for all saved."},
|
||
"days": {"type": "integer", "description": "Number of days to return (1–8). Default 1 (today only). Use 7 or 8 when the user asks for a weekly forecast or multiple days."},
|
||
},
|
||
read_only=True,
|
||
briefing=True,
|
||
)
|
||
async def get_weather_tool(*, user_id, arguments, **_ctx):
|
||
from fabledassistant.services.briefing_pipeline import _get_temp_unit
|
||
from fabledassistant.services.weather import (
|
||
_fetch_open_meteo,
|
||
geocode,
|
||
get_cached_weather,
|
||
parse_forecast,
|
||
refresh_location_cache,
|
||
)
|
||
|
||
location = (arguments.get("location") or "").strip()
|
||
num_days = max(1, min(8, int(arguments.get("days") or 1)))
|
||
temp_unit = await _get_temp_unit(user_id)
|
||
|
||
def _apply_unit(days: list[dict]) -> list[dict]:
|
||
if temp_unit != "F":
|
||
return days
|
||
def _c_to_f(v: float | None) -> float | None:
|
||
return round(v * 9 / 5 + 32, 1) if v is not None else None
|
||
return [
|
||
{**d, "temp_max": _c_to_f(d.get("temp_max")), "temp_min": _c_to_f(d.get("temp_min"))}
|
||
for d in days
|
||
]
|
||
|
||
_known = {"home", "work", "all", ""}
|
||
if location.lower() not in _known:
|
||
try:
|
||
lat, lon, label = await geocode(location)
|
||
raw = await _fetch_open_meteo(lat, lon)
|
||
days = _apply_unit(parse_forecast(raw))[:num_days]
|
||
return {"success": True, "data": {"locations": [{
|
||
"location_key": "query",
|
||
"location_label": label,
|
||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||
"days": days,
|
||
"temp_unit": temp_unit,
|
||
"changes_since_last_fetch": [],
|
||
}]}}
|
||
except Exception as e:
|
||
return {"success": False, "error": f"Could not get weather for '{location}': {e}"}
|
||
|
||
locations = await get_cached_weather(user_id)
|
||
if location.lower() not in ("all", ""):
|
||
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
|
||
now_utc = datetime.now(timezone.utc)
|
||
stale_cutoff = now_utc - timedelta(hours=6)
|
||
stale_keys = []
|
||
for loc in locations:
|
||
try:
|
||
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
|
||
except Exception:
|
||
fetched = None
|
||
if fetched is None or fetched < stale_cutoff:
|
||
stale_keys.append(loc["location_key"])
|
||
if stale_keys:
|
||
try:
|
||
from fabledassistant.services.settings import get_setting as _wx_get_setting
|
||
raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}")
|
||
cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {})
|
||
cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {}
|
||
for key in stale_keys:
|
||
meta = cfg_locs.get(key) or {}
|
||
if meta.get("lat") and meta.get("lon"):
|
||
try:
|
||
await refresh_location_cache(
|
||
user_id=user_id,
|
||
location_key=key,
|
||
location_label=meta.get("label", key),
|
||
lat=meta["lat"],
|
||
lon=meta["lon"],
|
||
)
|
||
except Exception:
|
||
logger.debug("Weather refresh failed for %s", key, exc_info=True)
|
||
locations = await get_cached_weather(user_id)
|
||
if location.lower() not in ("all", ""):
|
||
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
|
||
except Exception:
|
||
logger.debug("Weather staleness refresh failed", exc_info=True)
|
||
|
||
stamped_locations = []
|
||
for loc in locations:
|
||
days = _apply_unit(loc.get("days", []))[:num_days]
|
||
try:
|
||
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
|
||
age_h = (now_utc - fetched).total_seconds() / 3600.0
|
||
except Exception:
|
||
age_h = None
|
||
stamped_locations.append({
|
||
**loc,
|
||
"days": days,
|
||
"temp_unit": temp_unit,
|
||
"cache_age_hours": round(age_h, 1) if age_h is not None else None,
|
||
"is_stale": age_h is not None and age_h > 12.0,
|
||
})
|
||
return {"success": True, "data": {"locations": stamped_locations}}
|