Files
ams-weather/agents/custom/weather/weather.py
T

213 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Погодный сервис: геокодинг Open-Meteo + прогноз met.no.
Модуль намеренно ничего не знает про AMS и pydantic-ai — это чистый слой
получения данных. Его можно запустить и проверить отдельно:
python weather.py Казань Москва
Поддерживаются русские (кириллица) и латинские названия городов.
API-ключи не нужны: оба сервиса бесплатные.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from functools import lru_cache
from typing import Any
import requests
logger = logging.getLogger(__name__)
# --- Внешние сервисы ------------------------------------------------------
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/2.0/compact"
# met.no просит осмысленный User-Agent с контактом, иначе может блокировать.
USER_AGENT = "ams-weather-agent/1.0 (+https://git.totmin.ru/en2zmax/ams-weather)"
# (connect, read) в секундах. Держим короткими, чтобы не подвешивать диалог.
TIMEOUT = (4, 7)
RETRIES = 2 # общее число попыток на один HTTP-запрос
# Переиспользуем TCP-соединения между вызовами — заметно быстрее серии запросов.
_session = requests.Session()
_session.headers["User-Agent"] = USER_AGENT
class WeatherError(Exception):
"""Ожидаемая ошибка получения погоды.
Разделяем два уровня информации:
* message — короткий безопасный текст для пользователя;
* detail — подробности для лога (пользователю не показываем).
"""
def __init__(self, message: str, detail: str | None = None) -> None:
super().__init__(message)
self.message = message
self.detail = detail or message
@dataclass(frozen=True, slots=True)
class Location:
"""Найденный населённый пункт."""
name: str
region: str | None
country: str | None
latitude: float
longitude: float
@property
def title(self) -> str:
"""Человекочитаемое имя места: «Казань, Татарстан, Россия»."""
return ", ".join(part for part in (self.name, self.region, self.country) if part)
@dataclass(frozen=True, slots=True)
class Weather:
"""Текущие погодные условия."""
temperature_c: float
humidity_pct: int
wind_ms: float
wind_dir_deg: int
precipitation_mm: float
description: str
# met.no отдаёт symbol_code вида «partlycloudy_day»; суффикс дня/ночи отбрасываем.
_SYMBOLS: dict[str, str] = {
"clearsky": "ясно",
"fair": "малооблачно",
"partlycloudy": "переменная облачность",
"cloudy": "облачно",
"fog": "туман",
"lightrain": "небольшой дождь",
"rain": "дождь",
"heavyrain": "сильный дождь",
"lightrainshowers": "небольшой ливень",
"rainshowers": "ливень",
"heavyrainshowers": "сильный ливень",
"lightsleet": "небольшой дождь со снегом",
"sleet": "дождь со снегом",
"heavysleet": "сильный дождь со снегом",
"lightsnow": "небольшой снег",
"snow": "снег",
"heavysnow": "сильный снег",
"lightsnowshowers": "небольшой снегопад",
"snowshowers": "снегопад",
"heavysnowshowers": "сильный снегопад",
"rainandthunder": "дождь с грозой",
"sleetandthunder": "дождь со снегом и грозой",
"snowandthunder": "снег с грозой",
"thunderstorm": "гроза",
}
_UNKNOWN_DESCRIPTION = "нет данных"
def _get(url: str, params: dict[str, Any]) -> dict[str, Any]:
"""GET с короткими ретраями и единообразной обработкой ошибок.
Повторяем только временные сбои: сетевые ошибки, 5xx и 429.
Постоянные 4xx (например, 400) сразу превращаем в WeatherError —
повторять их бессмысленно.
"""
last_error: Exception | None = None
for attempt in range(1, RETRIES + 1):
try:
response = _session.get(url, params=params, timeout=TIMEOUT)
except requests.RequestException as exc:
last_error = exc
else:
status = response.status_code
if 400 <= status < 500 and status != 429:
raise WeatherError("внешний сервис погоды вернул ошибку", f"{url}: HTTP {status}")
if status < 500 and status != 429:
try:
return response.json()
except ValueError as exc: # ответ не является корректным JSON
last_error = exc
else:
last_error = requests.HTTPError(f"HTTP {status}")
logger.warning("Запрос %s не удался (попытка %d/%d): %s", url, attempt, RETRIES, last_error)
if attempt < RETRIES:
time.sleep(0.4 * attempt) # короткий нарастающий backoff
raise WeatherError("внешний сервис погоды недоступен", f"{url}: {last_error}")
@lru_cache(maxsize=256)
def geocode(city: str) -> Location:
"""Найти город по названию (кириллица или латиница).
Результат кэшируется: повторный запрос того же города отвечает мгновенно.
"""
city = city.strip()
if not city:
raise WeatherError("не указан город")
payload = _get(GEOCODE_URL, {"name": city, "count": 1, "language": "ru", "format": "json"})
results = payload.get("results") or []
if not results:
raise WeatherError(f"город «{city}» не найден")
top = results[0]
return Location(
name=top.get("name", city),
region=top.get("admin1"),
country=top.get("country"),
# met.no принимает координаты не более чем с 4 знаками после запятой.
latitude=round(float(top["latitude"]), 4),
longitude=round(float(top["longitude"]), 4),
)
def fetch_weather(location: Location) -> Weather:
"""Текущая погода по координатам (met.no)."""
payload = _get(FORECAST_URL, {"lat": location.latitude, "lon": location.longitude})
try:
series = payload["properties"]["timeseries"][0]
details = series["data"]["instant"]["details"]
next_hour = series["data"].get("next_1_hours") or {}
symbol = (next_hour.get("summary") or {}).get("symbol_code", "")
precipitation = (next_hour.get("details") or {}).get("precipitation_amount", 0.0)
return Weather(
temperature_c=round(float(details["air_temperature"]), 1),
humidity_pct=round(float(details["relative_humidity"])),
wind_ms=round(float(details["wind_speed"]), 1),
wind_dir_deg=round(float(details["wind_from_direction"])),
precipitation_mm=round(float(precipitation), 1),
description=_SYMBOLS.get(symbol.rsplit("_", 1)[0], _UNKNOWN_DESCRIPTION),
)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise WeatherError("сервис прогноза вернул неожиданный формат", repr(exc)) from exc
def format_summary(location: Location, weather: Weather) -> str:
"""Детерминированная сводка.
Используется как фолбэк, если языковая модель недоступна, — пользователь
в любом случае получает ответ по существу.
"""
return (
f"{location.title}: {weather.description}, {weather.temperature_c:g} °C, "
f"влажность {weather.humidity_pct} %, ветер {weather.wind_ms:g} м/с."
)
if __name__ == "__main__": # ручной smoke-тест: python weather.py Казань
import sys
logging.basicConfig(level=logging.INFO)
for query in sys.argv[1:] or ["Казань"]:
try:
found = geocode(query)
print(format_summary(found, fetch_weather(found)))
except WeatherError as exc:
print(f"{query}: ошибка — {exc.message}")