Ctrl K
ComunidadGuías…

weather.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""piSynapse Weather
Open-Meteo weather with Nominatim geocoding.
"""

import logging
from collections import OrderedDict

import httpx

import config

logger = logging.getLogger("piSynapse")

# Bounded LRU geocoding cache: Nominatim rate-limits (1 req/s) so we reuse
# results aggressively, but cap memory so long-running instances never grow
# the dict without bound.
_GEO_CACHE_MAX = 100
_geo_cache: "OrderedDict[str, tuple[str, str]]" = OrderedDict()
_client: httpx.AsyncClient | None = None


def _get_client() -> httpx.AsyncClient:
    global _client
    if _client is None or _client.is_closed:
        _client = httpx.AsyncClient(timeout=config.WEATHER_TIMEOUT)
    return _client


def _geo_lookup(city: str) -> tuple[str, str] | None:
    """Return cached lat/lon, or None to force a fresh geocode."""
    if city not in _geo_cache:
        return None
    _geo_cache.move_to_end(city)  # mark most-recently used
    return _geo_cache[city]


def _cache_city(city: str, lat: str, lon: str) -> None:
    _geo_cache[city] = (lat, lon)
    _geo_cache.move_to_end(city)
    while len(_geo_cache) > _GEO_CACHE_MAX:
        _geo_cache.popitem(last=False)  # evict least-recently used


def _wmo_condition(code: int | None) -> str:
    """Map a WMO weather code to a short Turkish condition label."""
    base = {
        0: "Açık", 1: "Az bulutlu", 2: "Parçalı bulutlu", 3: "Kapalı",
        45: "Sisli", 48: "Kırağılı sis",
        51: "Hafif çisenti", 53: "Çisenti", 55: "Yoğun çisenti",
        56: "Hafif donan çisenti", 57: "Donan çisenti",
        61: "Hafif yağmur", 63: "Yağmurlu", 65: "Yoğun yağmur",
        66: "Hafif donan yağmur", 67: "Donan yağmur",
        71: "Hafif kar", 73: "Karlı", 75: "Yoğun kar", 77: "Kar tanesi",
        80: "Hafif sağanak", 81: "Sağanak", 82: "Şiddetli sağanak",
        85: "Kar sağanağı", 86: "Yoğun kar sağanağı",
    }
    if code in base:
        return base[code]
    if code is not None and 95 <= code <= 99:
        return "Gök gürültülü" + (" dolu" if code >= 96 else "")
    return "Bilinmiyor"


def _wmo_kind(code: int | None) -> str:
    """Coarse condition category — the widget's icon key (clear/partly/...)."""
    if code is None:
        return "unknown"
    if code == 0:
        return "clear"
    if code in (1, 2, 3):
        return "partly" if code in (1, 2) else "cloud"
    if code in (45, 48):
        return "fog"
    if 51 <= code <= 57:
        return "drizzle"
    if (61 <= code <= 67) or (80 <= code <= 82):
        return "rain"
    if (71 <= code <= 77) or code in (85, 86):
        return "snow"
    if 95 <= code <= 99:
        return "storm"
    return "unknown"


async def _weather_data(city: str = "") -> dict | None:
    """Structured weather payload for the widget. Returns None on any failure
    (geocoding miss, HTTP error, parsing). City is always resolved before use.
    """
    city = city or config.DEFAULT_CITY or "London"
    client = _get_client()
    coords = _geo_lookup(city)
    if coords is None:
        geo = await client.get(
            "https://nominatim.openstreetmap.org/search",
            params={"q": city, "format": "json", "limit": 1},
            headers={"User-Agent": "piSynapse/1.0"},
        )
        gd = geo.json()
        if not gd:
            return None
        coords = (gd[0]["lat"], gd[0]["lon"])
        _cache_city(city, *coords)
    lat, lon = coords
    w = await client.get(
        "https://api.open-meteo.com/v1/forecast",
        params={
            "latitude": lat, "longitude": lon,
            "current": "temperature_2m,apparent_temperature,weather_code",
            "timezone": "auto",
        },
        headers={"User-Agent": "piSynapse/1.0"},
    )
    c = w.json()["current"]
    code = c.get("weather_code")
    return {
        "city": city,
        "temp_c": c["temperature_2m"],
        "feels_c": c.get("apparent_temperature"),
        "condition": _wmo_condition(code),
        "wmo_code": code,
        "kind": _wmo_kind(code),
    }


def format_weather_summary(data: dict) -> str:
    """Single shared '<city>: <temp>°C, <condition>[, feels like …]' format.

    Used by both the tool reply (get_weather) and the sidebar widget so a
    format change cannot silently fork the two surfaces.
    """
    feels = data.get("feels_c")
    base = f"{data.get('city')}: {data.get('temp_c')}°C, {data.get('condition')}"
    return f"{base}, feels like {feels}°C" if feels is not None else base


async def get_weather(city: str = "") -> str:
    city = city or config.DEFAULT_CITY or "London"
    try:
        data = await _weather_data(city)
        if data is None:
            return f"ERROR: City not found: {city}"
        return format_weather_summary(data)
    except Exception as e:
        logger.error(f"Weather API error: {e}")
        return "ERROR: unable to fetch weather data"