From 534ff9c5b09463236c7891b4914b389164287453 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Wed, 23 Sep 2026 19:03:10 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BE=D0=B3=D0=BE=D0=B4=D0=B0=C2=BB=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20AMS=20(Open-Meteo=20+=20met.no)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 13 ++ README.md | 119 +++++++++++++ agents/custom/weather/config.json | 4 + agents/custom/weather/handler.py | 122 ++++++++++++++ agents/custom/weather/schema.py | 21 +++ agents/custom/weather/system_prompt.txt | 9 + agents/custom/weather/weather.py | 212 ++++++++++++++++++++++++ pyproject.toml | 16 ++ tests/test_weather.py | 180 ++++++++++++++++++++ 9 files changed, 696 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agents/custom/weather/config.json create mode 100644 agents/custom/weather/handler.py create mode 100644 agents/custom/weather/schema.py create mode 100644 agents/custom/weather/system_prompt.txt create mode 100644 agents/custom/weather/weather.py create mode 100644 pyproject.toml create mode 100644 tests/test_weather.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb6a6df --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.ruff_cache/ + +# IDE / OS +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb44525 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# AMS Weather Agent + +Агент для **AMS (Agent Management System)**: по названию города возвращает короткую +сводку о текущей погоде. Понимает русские (кириллица) и латинские названия. + +``` +Пользователь: Казань +Агент: В Казани переменная облачность, температура 22,2 °C. Ветер 4,1 м/с, + осадков нет. Возьмите лёгкую ветровку. +``` + +## Возможности + +- Кириллица и латиница: `Казань`, `Нижний Новгород`, `Санкт-Петербург`, `Kazan`. +- Данные о погоде: температура, влажность, ветер (скорость и направление), осадки. +- Ответ формулирует языковая модель, но **только из переданных фактов** — числа не выдумываются. +- **Graceful degradation**: если LLM недоступна, агент отдаёт детерминированную сводку. +- Понятные сообщения об ошибках вместо исключений (диалог в AMS не падает). +- Кэширование геокодинга и переиспользование HTTP-соединений. +- API-ключи не нужны: оба внешних сервиса бесплатные. + +## Источники данных + +| Этап | Сервис | Ключ | +|---|---|---| +| Город → координаты | [Open-Meteo Geocoding](https://open-meteo.com/en/docs/geocoding-api) | не нужен | +| Координаты → погода | [MET Norway Locationforecast](https://api.met.no/weatherapi/locationforecast/2.0/documentation) | не нужен | + +## Структура + +``` +agents/custom/weather/ +├── config.json # метаданные агента для UI (name, description) +├── schema.py # Pydantic-модель Params (валидация входа в AMS) +├── handler.py # адаптер AMS: экспортирует генератор step(...) +├── system_prompt.txt # правила формулировки ответа для LLM +└── weather.py # сервисный слой: HTTP, модели, маппинг погодных кодов +tests/ +└── test_weather.py # unit-тесты (без сети) +``` + +Разделение ответственности: `weather.py` ничего не знает про AMS и тестируется +отдельно; `handler.py` — тонкий слой интеграции. + +## Требования + +- AMS 7.005+ (Python 3.12, `requests` и `pydantic-ai` уже в окружении сервиса). +- Доступ к `geocoding-api.open-meteo.com` и `api.met.no` с хоста AMS. + +## Установка в AMS + +Скопировать каталог агента и выдать права пользователю сервиса: + +```bash +sudo cp -r agents/custom/weather /opt/ams/agents/custom/weather +sudo chown -R lexicom:lexicom /opt/ams/agents/custom/weather +``` + +Loader AMS подхватит изменения примерно через 10 секунд. Проверка: + +```bash +curl -s http://127.0.0.1:6165/ams/agent/list | jq '.agents.custom' +``` + +Альтернатива — импорт через API AMS (zip-архив агента). + +## Использование (API AMS) + +`POST /ams/start/{type}/{agent_id}` с телом в формате JSON. Синхронный стрим +удобнее для клиентов, которые хотят получить ответ в одном запросе +(`async_mode: "no"`): + +```bash +curl -sN http://127.0.0.1:6165/ams/start/custom/weather \ + -H 'Content-Type: application/json' \ + -H 'Accept: text/event-stream' \ + -d '{"async_mode":"no","params":{"city":"Казань"}}' +``` + +Ответ — поток JSON-строк (NDJSON): + +```json +{"type": "chunk", "data": "В Казани ", "chat_id": "..."} +{"type": "chunk", "data": "переменная облачность...", "chat_id": "..."} +{"type": "end", "data": "message", "chat_id": "..."} +``` + +Асинхронный режим (`async_mode: "yes"`) возвращает `chat_id`; далее читать +`GET /ams/stream/{chat_id}`, продолжать диалог `POST /ams/ask/{chat_id}` и +завершать `POST /ams/stop/{chat_id}`. + +### Параметры + +| Параметр | Тип | Описание | +|---|---|---| +| `city` | string | Название города (кириллица или латиница). | + +## Поведение при ошибках + +| Ситуация | Ответ | +|---|---| +| Город не найден | «Не удалось получить погоду: город «…» не найден.» | +| Пустой город | «Не удалось получить погоду: не указан город.» | +| Внешний сервис недоступен | «Не удалось получить погоду: внешний сервис погоды недоступен.» | +| LLM недоступна | Детерминированная сводка из полученных данных | + +## Тесты + +```bash +python -m venv .venv && . .venv/bin/activate +pip install pytest requests +pytest +``` + +Тесты не обращаются к сети: HTTP-слой подменяется (`monkeypatch`). + +## Лицензия + +Не задана (все права сохранены). diff --git a/agents/custom/weather/config.json b/agents/custom/weather/config.json new file mode 100644 index 0000000..b0ee078 --- /dev/null +++ b/agents/custom/weather/config.json @@ -0,0 +1,4 @@ +{ + "name": "Погода", + "description": "Присылает сводку о текущей погоде по названию города (кириллица или латиница)." +} diff --git a/agents/custom/weather/handler.py b/agents/custom/weather/handler.py new file mode 100644 index 0000000..99bba64 --- /dev/null +++ b/agents/custom/weather/handler.py @@ -0,0 +1,122 @@ +"""AMS-агент «Погода». + +Назначение: по названию города вернуть короткую сводку о текущей погоде. + +Архитектура (разделение ответственности): + * weather.py — получение данных (геокодинг + прогноз); ничего не знает про AMS; + * handler.py — тонкий адаптер AMS: вызывает сервис и отдаёт ответ стримом. + +Модуль не выпускает исключений наружу: любая ошибка превращается в текстовый +чанк. Это принципиально, потому что воркер AMS при исключении из step() +прерывает диалог и не отправляет клиенту ничего. + +Контракт AMS: модуль экспортирует генератор step(...), который AMS вызывает +по именам аргументов: step(params=..., state=..., agent=...). +Отдавать нужно словари вида {"type": "chunk", "data": "..."}. +""" +from __future__ import annotations + +import importlib.util +import logging +import os +import sys +from typing import Any, Iterator + +from pydantic_ai import ModelSettings + +logger = logging.getLogger(__name__) + +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _load_sibling(module_name: str, filename: str) -> Any: + """Импортировать модуль из каталога агента. + + AMS загружает handler.py как отдельный модуль (не пакет), а каталог агента + не входит в sys.path. Поэтому соседний модуль подключаем явно и под + уникальным именем — это исключает конфликты имён с другими агентами. + + Модуль обязательно регистрируем в sys.modules: этого требуют dataclasses + (при отложенных аннотациях) и pickle. + """ + spec = importlib.util.spec_from_file_location(module_name, os.path.join(CURRENT_DIR, filename)) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) # не оставляем «полумодуль» в реестре + raise + return module + + +weather = _load_sibling("ams_weather_service", "weather.py") + + +def _read_text(filename: str) -> str: + """Прочитать текстовый ресурс, лежащий рядом с обработчиком.""" + with open(os.path.join(CURRENT_DIR, filename), encoding="utf-8") as file: + return file.read() + + +SYSTEM_PROMPT = _read_text("system_prompt.txt") + +# temperature — модель только оформляет факты, без «творчества». +# enable_thinking отключён: иначе Qwen тратит время на reasoning-токены, +# а max_tokens ограничивает длину ответа (ответ по заданию короткий). +MODEL_SETTINGS = ModelSettings( + temperature=0.2, + max_tokens=256, + extra_body={"chat_template_kwargs": {"enable_thinking": False}}, +) + + +def _facts(location: weather.Location, current: weather.Weather) -> str: + """Факты для языковой модели. + + Все значения передаём явно, чтобы модель не додумывала числа. + """ + return ( + f"Город: {location.title}\n" + f"Состояние: {current.description}\n" + f"Температура: {current.temperature_c} °C\n" + f"Влажность: {current.humidity_pct} %\n" + f"Ветер: {current.wind_ms} м/с, направление {current.wind_dir_deg}°\n" + f"Осадки: {current.precipitation_mm} мм/час" + ) + + +def _run(agent: Any, params: dict[str, Any]) -> Iterator[dict[str, Any]]: + """Основной сценарий: город -> сводка о погоде.""" + # Запрос относится к конкретному городу, поэтому не тащим контекст прошлых. + agent.history = [] + agent.set_system_prompt(SYSTEM_PROMPT) + + try: + location = weather.geocode(params["city"]) + current = weather.fetch_weather(location) + except weather.WeatherError as exc: + logger.warning("Погода недоступна: %s", exc.detail) + yield {"type": "chunk", "data": f"Не удалось получить погоду: {exc.message}."} + return + + summary = weather.format_summary(location, current) + + # Языковая модель только переформулирует факты. Если она недоступна — + # отдаём детерминированную сводку, чтобы ответ был в любом случае. + try: + with agent.run_stream_sync(_facts(location, current), model_settings=MODEL_SETTINGS) as stream: + for chunk in stream.stream_text(delta=True): + yield {"type": "chunk", "data": chunk} + except Exception: # noqa: BLE001 — намеренно широкий: нужен гарантированный ответ + logger.exception("LLM недоступен, отдаю детерминированную сводку") + yield {"type": "chunk", "data": summary} + + +def step(agent: Any, state: dict[str, Any], params: dict[str, Any]) -> Iterator[dict[str, Any]]: + """Точка входа AMS. Гарантированно не поднимает исключений.""" + try: + yield from _run(agent, params) + except Exception: # noqa: BLE001 — последний рубеж: диалог не должен падать + logger.exception("Непредвиденная ошибка обработки запроса") + yield {"type": "chunk", "data": "Не удалось обработать запрос, попробуйте позже."} diff --git a/agents/custom/weather/schema.py b/agents/custom/weather/schema.py new file mode 100644 index 0000000..b2e4c14 --- /dev/null +++ b/agents/custom/weather/schema.py @@ -0,0 +1,21 @@ +"""Входные параметры агента «Погода». + +AMS валидирует входящие params этой моделью до вызова handler.step. + +Важно: loader AMS создаёт `Params()` без аргументов (для интроспекции UI), +поэтому у всех полей обязаны быть значения по умолчанию. Пустой город +отсекается уже в handler'е понятным сообщением. +""" +from pydantic import BaseModel, ConfigDict, Field + + +class Params(BaseModel): + # Обрезаем случайные пробелы по краям — « Казань » и «Казань» эквивалентны. + model_config = ConfigDict(str_strip_whitespace=True) + + city: str = Field( + default="", + max_length=100, + description="Название города (кириллица или латиница), например «Казань»", + json_schema_extra={"ui_type": "input", "ui_main": True}, + ) diff --git a/agents/custom/weather/system_prompt.txt b/agents/custom/weather/system_prompt.txt new file mode 100644 index 0000000..582b324 --- /dev/null +++ b/agents/custom/weather/system_prompt.txt @@ -0,0 +1,9 @@ +Ты — погодный ассистент. На вход приходят уже готовые данные о погоде. + +Правила: +- Составь короткую сводку на русском языке (2–3 предложения), тон — спокойный и деловой. +- Используй ТОЛЬКО переданные числа и факты. Ничего не додумывай и не добавляй. +- Обязательно назови город, температуру и общее состояние погоды. +- Уместно добавь один короткий практический совет (зонт, ветровка, шапка) — без лишних слов. +- Не задавай вопросов и не предлагай выбирать другие города. +- Если данных не хватает — просто перечисли то, что есть, без домыслов. diff --git a/agents/custom/weather/weather.py b/agents/custom/weather/weather.py new file mode 100644 index 0000000..2755d7b --- /dev/null +++ b/agents/custom/weather/weather.py @@ -0,0 +1,212 @@ +"""Погодный сервис: геокодинг 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}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f92f0a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "ams-weather-agent" +version = "1.0.0" +description = "AMS-агент «Погода»: сводка о текущей погоде по названию города" +requires-python = ">=3.12" +dependencies = ["requests>=2.31"] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.6"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 110 +target-version = "py312" diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..5fcd72e --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,180 @@ +"""Unit-тесты погодного сервиса. Сеть не используется: HTTP-слой подменяется. + +Запуск: pytest +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# weather.py лежит внутри каталога агента и не является пакетом, +# поэтому для тестов добавляем его каталог в sys.path. +AGENT_DIR = Path(__file__).resolve().parents[1] / "agents" / "custom" / "weather" +sys.path.insert(0, str(AGENT_DIR)) + +import weather # noqa: E402 + +LOCATION = weather.Location("Казань", "Татарстан", "Россия", 55.7887, 49.1221) + +GEOCODE_PAYLOAD = { + "results": [ + { + "name": "Казань", + "admin1": "Татарстан", + "country": "Россия", + "latitude": 55.78874, + "longitude": 49.12214, + } + ] +} + +FORECAST_PAYLOAD = { + "properties": { + "timeseries": [ + { + "data": { + "instant": { + "details": { + "air_temperature": 22.2, + "relative_humidity": 54.0, + "wind_speed": 4.1, + "wind_from_direction": 103.0, + } + }, + "next_1_hours": { + "summary": {"symbol_code": "partlycloudy_day"}, + "details": {"precipitation_amount": 0.0}, + }, + } + } + ] + } +} + + +@pytest.fixture(autouse=True) +def clear_geocode_cache(): + """Кэш геокодера — глобальный, поэтому чистим его перед каждым тестом.""" + weather.geocode.cache_clear() + yield + weather.geocode.cache_clear() + + +# --- geocode --------------------------------------------------------------- + +def test_geocode_empty_city_raises(): + with pytest.raises(weather.WeatherError) as exc: + weather.geocode(" ") + assert exc.value.message == "не указан город" + + +def test_geocode_not_found(monkeypatch): + monkeypatch.setattr(weather, "_get", lambda url, params: {"results": []}) + with pytest.raises(weather.WeatherError) as exc: + weather.geocode("Абракадабрания") + assert "не найден" in exc.value.message + + +def test_geocode_builds_location_and_rounds_coordinates(monkeypatch): + monkeypatch.setattr(weather, "_get", lambda url, params: GEOCODE_PAYLOAD) + location = weather.geocode("Казань") + + assert location.title == "Казань, Татарстан, Россия" + # met.no принимает не более 4 знаков после запятой. + assert location.latitude == 55.7887 + assert location.longitude == 49.1221 + + +def test_geocode_is_cached(monkeypatch): + calls: list[str] = [] + + def fake_get(url, params): + calls.append(params["name"]) + return GEOCODE_PAYLOAD + + monkeypatch.setattr(weather, "_get", fake_get) + weather.geocode("Казань") + weather.geocode("Казань") + + assert calls == ["Казань"] # второй вызов обслужен из кэша + + +# --- fetch_weather --------------------------------------------------------- + +def test_fetch_weather_maps_fields(monkeypatch): + monkeypatch.setattr(weather, "_get", lambda url, params: FORECAST_PAYLOAD) + current = weather.fetch_weather(LOCATION) + + assert current.temperature_c == 22.2 + assert current.humidity_pct == 54 + assert current.wind_ms == 4.1 + assert current.wind_dir_deg == 103 + assert current.precipitation_mm == 0.0 + assert current.description == "переменная облачность" + + +@pytest.mark.parametrize( + ("symbol", "expected"), + [("clearsky_night", "ясно"), ("snow", "снег"), ("unknowncode", "нет данных")], +) +def test_fetch_weather_strips_symbol_suffix(monkeypatch, symbol, expected): + payload = { + "properties": { + "timeseries": [ + { + "data": { + "instant": { + "details": { + "air_temperature": 0.0, + "relative_humidity": 90.0, + "wind_speed": 1.0, + "wind_from_direction": 0.0, + } + }, + "next_1_hours": {"summary": {"symbol_code": symbol}, "details": {}}, + } + } + ] + } + } + monkeypatch.setattr(weather, "_get", lambda url, params: payload) + assert weather.fetch_weather(LOCATION).description == expected + + +def test_fetch_weather_bad_payload_raises(monkeypatch): + monkeypatch.setattr(weather, "_get", lambda url, params: {}) + with pytest.raises(weather.WeatherError): + weather.fetch_weather(LOCATION) + + +# --- _get ------------------------------------------------------------------ + +def test_get_does_not_retry_4xx(monkeypatch): + calls = {"n": 0} + + class Response: + status_code = 404 + + class Session: + def get(self, *args, **kwargs): + calls["n"] += 1 + return Response() + + monkeypatch.setattr(weather, "_session", Session()) + with pytest.raises(weather.WeatherError): + weather._get("https://example.invalid", {}) + + assert calls["n"] == 1 # постоянную 4xx не повторяем + + +# --- format_summary -------------------------------------------------------- + +def test_format_summary_contains_key_facts(): + current = weather.Weather(22.2, 54, 4.1, 103, 0.0, "переменная облачность") + text = weather.format_summary(LOCATION, current) + + assert "Казань, Татарстан, Россия" in text + assert "переменная облачность" in text + assert "22.2 °C" in text