import time from typing import Any import httpx from app.config import get_settings WEATHER_CODES: dict[int, str] = { 0: "ясно", 1: "преимущественно ясно", 2: "переменная облачность", 3: "пасмурно", 45: "туман", 48: "изморозь", 51: "морось", 53: "морось", 55: "морось", 61: "дождь", 63: "дождь", 65: "сильный дождь", 71: "снег", 73: "снег", 75: "сильный снег", 80: "ливень", 81: "ливень", 82: "сильный ливень", 95: "гроза", 96: "гроза с градом", 99: "гроза с градом", } _cache: dict[str, Any] = { "data": None, "fetched_at": 0.0, "expires_at": 0.0, "source": "local", "local_coverage": {"current": [], "hourly": []}, } CURRENT_FIELDS = ( "temperature_2m", "apparent_temperature", "relative_humidity_2m", "precipitation", "weather_code", "wind_speed_10m", ) HOURLY_FIELDS = ( "temperature_2m", "precipitation_probability", "precipitation", "weather_code", ) RECOMMENDED_SYNC_DOMAINS = "dwd_icon,ncep_gfs013,ncep_gefs025" RECOMMENDED_SYNC_VARIABLES = ( "temperature_2m,dew_point_2m,relative_humidity_2m,precipitation_probability," "precipitation,rain,cloud_cover,weather_code,wind_u_component_10m,wind_v_component_10m" ) SYNC_HINT = ( "Контейнер open-meteo-sync, скорее всего, качает только temperature_2m. " f"Задай SYNC_DOMAINS={RECOMMENDED_SYNC_DOMAINS} и " f"SYNC_VARIABLES={RECOMMENDED_SYNC_VARIABLES} (~12 GB). " "Документация: github.com/open-meteo/open-data/tree/main/tutorial_weather_api" ) def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]: values = hourly.get(key) return values if isinstance(values, list) else [] def _hourly_start_index(times: list[str], anchor_time: str | None) -> int: if not times: return 0 if not anchor_time: return 0 best = 0 for i, t in enumerate(times): if t <= anchor_time: best = i else: break return best def _field_coverage(raw: dict[str, Any]) -> dict[str, list[str]]: """Какие поля реально пришли от OpenMeteo (не null).""" current = raw.get("current") or {} hourly = raw.get("hourly") or {} current_present = [ key for key in CURRENT_FIELDS if current.get(key) is not None ] hourly_present = [] for key in HOURLY_FIELDS: series = _hourly_series(hourly, key) if any(v is not None for v in series): hourly_present.append(key) return {"current": current_present, "hourly": hourly_present} def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool: current = set(coverage.get("current") or []) hourly = set(coverage.get("hourly") or []) if "weather_code" not in current: return False if len(current) < 3: return False if "precipitation_probability" not in hourly and "weather_code" not in hourly: return False return True def _fmt_num(value: Any, *, suffix: str = "") -> str: if value is None: return "—" if isinstance(value, float): text = f"{value:.1f}".rstrip("0").rstrip(".") else: text = str(value) return f"{text}{suffix}" if suffix else text class OpenMeteoClient: def __init__(self) -> None: settings = get_settings() self.base_url = settings.openmeteo_base_url.rstrip("/") self.fallback_url = (settings.openmeteo_fallback_url or "").strip().rstrip("/") self.fallback_on_partial = settings.openmeteo_fallback_on_partial self.lat = settings.weather_lat self.lon = settings.weather_lon self.location_name = settings.weather_location_name self.cache_ttl = settings.weather_cache_sec def _request_params(self) -> dict[str, Any]: return { "latitude": self.lat, "longitude": self.lon, "current": ",".join(CURRENT_FIELDS), "hourly": ",".join(HOURLY_FIELDS), "timezone": "auto", "forecast_days": 2, } def _fetch_from_url(self, base_url: str) -> dict[str, Any]: with httpx.Client(timeout=20.0) as client: response = client.get(f"{base_url.rstrip('/')}/v1/forecast", params=self._request_params()) response.raise_for_status() return response.json() def _fetch_raw(self) -> dict[str, Any]: now = time.time() if _cache["data"] and now < _cache["expires_at"]: return _cache["data"] local_raw = self._fetch_from_url(self.base_url) local_coverage = _field_coverage(local_raw) source = "local" raw = local_raw if ( self.fallback_on_partial and self.fallback_url and self.fallback_url.rstrip("/") != self.base_url and not _coverage_sufficient(local_coverage) ): try: fallback_raw = self._fetch_from_url(self.fallback_url) if _coverage_sufficient(_field_coverage(fallback_raw)): raw = fallback_raw source = "fallback" except Exception: pass _cache["data"] = raw _cache["fetched_at"] = now _cache["expires_at"] = now + self.cache_ttl _cache["source"] = source _cache["local_coverage"] = local_coverage return raw def cache_status(self) -> dict[str, Any]: now = time.time() fetched_at = float(_cache.get("fetched_at") or 0) expires_at = float(_cache.get("expires_at") or 0) has_data = _cache.get("data") is not None age_sec = int(now - fetched_at) if fetched_at else None expires_in_sec = max(0, int(expires_at - now)) if expires_at else None return { "has_data": has_data, "cached": bool(has_data and expires_at and now < expires_at), "fetched_at": fetched_at or None, "age_sec": age_sec, "ttl_sec": self.cache_ttl, "expires_in_sec": expires_in_sec, "source": _cache.get("source") or "local", } def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]: try: raw = self._fetch_raw() except Exception as exc: return {"ok": False, "error": str(exc), "location": self.location_name} current = raw.get("current") or {} hourly = raw.get("hourly") or {} times = hourly.get("time") or [] start = _hourly_start_index(times, current.get("time")) end = min(start + hours_ahead, len(times)) hourly_slice = [] for i in range(start, end): code = _hourly_series(hourly, "weather_code")[i] if i < len(_hourly_series(hourly, "weather_code")) else None temp_series = _hourly_series(hourly, "temperature_2m") precip_series = _hourly_series(hourly, "precipitation") prob_series = _hourly_series(hourly, "precipitation_probability") hourly_slice.append({ "time": times[i], "temperature_c": temp_series[i] if i < len(temp_series) else None, "precipitation_mm": precip_series[i] if i < len(precip_series) else None, "precipitation_probability": prob_series[i] if i < len(prob_series) else None, "weather_code": code, "conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно", }) code = current.get("weather_code") coverage = _field_coverage(raw) return { "ok": True, "location": self.location_name, "data_source": _cache.get("source") or "local", "local_field_coverage": _cache.get("local_coverage") or coverage, "field_coverage": coverage, "sync_hint": SYNC_HINT if not _coverage_sufficient(_cache.get("local_coverage") or coverage) else "", "current": { "time": current.get("time"), "temperature_c": current.get("temperature_2m"), "apparent_temperature_c": current.get("apparent_temperature"), "humidity_pct": current.get("relative_humidity_2m"), "precipitation_mm": current.get("precipitation"), "wind_speed_kmh": current.get("wind_speed_10m"), "weather_code": code, "conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно", }, "hourly": hourly_slice, } def rain_summary(self, hours_ahead: int = 12) -> str: data = self.fetch_current_and_hourly(hours_ahead=hours_ahead) if not data.get("ok"): return f"Погода недоступна: {data.get('error', 'ошибка')}" rainy_hours = [] for hour in data.get("hourly") or []: prob = hour.get("precipitation_probability") precip = hour.get("precipitation_mm") or 0 if (prob is not None and prob >= 40) or precip > 0: time_str = (hour.get("time") or "")[11:16] rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)") if rainy_hours: return "Ожидаются осадки: " + ", ".join(rainy_hours[:6]) return "Существенных осадков в ближайшие часы не ожидается." def format_weather_snapshot(data: dict[str, Any] | None = None) -> str: client = OpenMeteoClient() snapshot = data if data is not None else client.fetch_current_and_hourly(hours_ahead=6) lines = ["[Погода]"] if not snapshot.get("ok"): lines.append(f"Данные недоступны ({snapshot.get('error', 'ошибка')}).") lines.append("Для точного ответа вызови get_weather.") return "\n".join(lines) cur = snapshot.get("current") or {} apparent = cur.get("apparent_temperature_c") wind = cur.get("wind_speed_kmh") apparent_part = f", ощущается {_fmt_num(apparent, suffix='°C')}" if apparent is not None else "" wind_part = f", ветер {_fmt_num(wind, suffix=' км/ч')}" if wind is not None else "" lines.append( f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}" f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}." ) hourly = snapshot.get("hourly") or [] rainy_hours = [] for hour in hourly: prob = hour.get("precipitation_probability") precip = hour.get("precipitation_mm") or 0 if (prob is not None and prob >= 40) or precip > 0: time_str = (hour.get("time") or "")[11:16] rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)") if rainy_hours: lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6])) else: lines.append("Существенных осадков в ближайшие часы не ожидается.") lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.") return "\n".join(lines) def build_weather_dashboard(hours_ahead: int = 12) -> dict[str, Any]: """Полный снимок для UI: данные OpenMeteo + контекст ассистента.""" client = OpenMeteoClient() weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead) settings = get_settings() return { "weather": weather, "rain_summary": client.rain_summary(hours_ahead=hours_ahead) if weather.get("ok") else "", "assistant_context": format_weather_snapshot(weather), "cache": client.cache_status(), "config": { "location": client.location_name, "latitude": client.lat, "longitude": client.lon, "openmeteo_base_url": client.base_url, "cache_ttl_sec": client.cache_ttl, "forecast_days": 2, "timezone": "auto", }, "available_fields": { "current": list(CURRENT_FIELDS), "hourly": list(HOURLY_FIELDS), }, "field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": []}, "local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": []}, "data_source": weather.get("data_source", "local") if weather.get("ok") else "local", "sync_hint": weather.get("sync_hint", "") if weather.get("ok") else SYNC_HINT, "recommended_sync": { "domains": RECOMMENDED_SYNC_DOMAINS, "variables": RECOMMENDED_SYNC_VARIABLES, }, "assistant_tools": { "get_weather": "Текущая погода и почасовой прогнос (hours_ahead до 48)", "get_morning_briefing": "Погода + заголовки RSS-новостей", }, "system_prompt": "Краткий блок [Погода] в system prompt каждого сообщения (6 ч почасово).", }