refactor(tools): decorator-based tool registry replaces monolithic tools.py
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>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""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}}
|
||||
Reference in New Issue
Block a user