526 lines
21 KiB
Python
526 lines
21 KiB
Python
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: "гроза с градом",
|
||
}
|
||
|
||
WEATHER_QUERY_KEYWORDS = (
|
||
"погод", "дожд", "снег", "ветер", "температур", "градус", "мороз", "жар",
|
||
"на улице", "одеть", "зонт", "прогноз", "завтра", "послезавтра", "выходн",
|
||
"weather", "rain", "forecast", "umbrella", "outside",
|
||
)
|
||
|
||
_cache: dict[str, Any] = {
|
||
"data": None,
|
||
"fetched_at": 0.0,
|
||
"expires_at": 0.0,
|
||
"source": "local",
|
||
"local_coverage": {"current": [], "hourly": [], "daily": []},
|
||
"merged_fields": [],
|
||
}
|
||
|
||
CURRENT_FIELDS = (
|
||
"temperature_2m",
|
||
"apparent_temperature",
|
||
"relative_humidity_2m",
|
||
"precipitation",
|
||
"weather_code",
|
||
"wind_speed_10m",
|
||
)
|
||
HOURLY_FIELDS = (
|
||
"temperature_2m",
|
||
"precipitation_probability",
|
||
"precipitation",
|
||
"weather_code",
|
||
)
|
||
DAILY_FIELDS = (
|
||
"weather_code",
|
||
"temperature_2m_max",
|
||
"temperature_2m_min",
|
||
"precipitation_sum",
|
||
"precipitation_probability_max",
|
||
"wind_speed_10m_max",
|
||
)
|
||
|
||
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 отдаёт неполные данные. "
|
||
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"
|
||
)
|
||
PRECIP_PROB_HINT = (
|
||
"Для вероятности дождя добавь ncep_gefs025 в SYNC_DOMAINS "
|
||
"и precipitation_probability в SYNC_VARIABLES."
|
||
)
|
||
|
||
|
||
def weather_query_relevant(query: str) -> bool:
|
||
q = (query or "").lower()
|
||
return any(kw in q for kw in WEATHER_QUERY_KEYWORDS)
|
||
|
||
|
||
def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]:
|
||
values = hourly.get(key)
|
||
return values if isinstance(values, list) else []
|
||
|
||
|
||
def _daily_series(daily: dict[str, Any], key: str) -> list[Any]:
|
||
values = daily.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]]:
|
||
current = raw.get("current") or {}
|
||
hourly = raw.get("hourly") or {}
|
||
daily = raw.get("daily") 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)
|
||
daily_present = []
|
||
for key in DAILY_FIELDS:
|
||
series = _daily_series(daily, key)
|
||
if any(v is not None for v in series):
|
||
daily_present.append(key)
|
||
return {"current": current_present, "hourly": hourly_present, "daily": daily_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 "weather_code" not in hourly and "temperature_2m" not in hourly:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _local_needs_sync_hint(local_coverage: dict[str, list[str]]) -> bool:
|
||
current = set(local_coverage.get("current") or [])
|
||
hourly = set(local_coverage.get("hourly") or [])
|
||
if "temperature_2m" not in current:
|
||
return True
|
||
if "weather_code" not in current:
|
||
return True
|
||
if "temperature_2m" not in hourly:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _missing_precip_probability(coverage: dict[str, list[str]]) -> bool:
|
||
return "precipitation_probability" not in set(coverage.get("hourly") or [])
|
||
|
||
|
||
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
|
||
|
||
|
||
def _conditions(code: Any) -> str:
|
||
if code is None:
|
||
return "неизвестно"
|
||
return WEATHER_CODES.get(int(code), "неизвестно")
|
||
|
||
|
||
def _format_day_label(date_str: str, index: int) -> str:
|
||
if index == 0:
|
||
return "Сегодня"
|
||
if index == 1:
|
||
return "Завтра"
|
||
if not date_str:
|
||
return f"День {index + 1}"
|
||
parts = date_str.split("-")
|
||
if len(parts) == 3:
|
||
return f"{parts[2]}.{parts[1]}"
|
||
return date_str
|
||
|
||
|
||
def _merge_hourly_field(target: dict[str, Any], source: dict[str, Any], field: str) -> bool:
|
||
hourly_t = target.setdefault("hourly", {})
|
||
hourly_s = source.get("hourly") or {}
|
||
src = hourly_s.get(field)
|
||
if not isinstance(src, list) or not any(v is not None for v in src):
|
||
return False
|
||
dst = hourly_t.get(field)
|
||
if isinstance(dst, list) and len(dst) == len(src):
|
||
hourly_t[field] = [
|
||
dst[i] if dst[i] is not None else src[i]
|
||
for i in range(len(src))
|
||
]
|
||
else:
|
||
hourly_t[field] = src
|
||
return True
|
||
|
||
|
||
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
|
||
self.forecast_days = max(2, int(settings.weather_forecast_days or 7))
|
||
|
||
def _request_params(self) -> dict[str, Any]:
|
||
return {
|
||
"latitude": self.lat,
|
||
"longitude": self.lon,
|
||
"current": ",".join(CURRENT_FIELDS),
|
||
"hourly": ",".join(HOURLY_FIELDS),
|
||
"daily": ",".join(DAILY_FIELDS),
|
||
"timezone": "auto",
|
||
"forecast_days": self.forecast_days,
|
||
}
|
||
|
||
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
|
||
merged_fields: list[str] = []
|
||
|
||
need_fallback = (
|
||
self.fallback_on_partial
|
||
and self.fallback_url
|
||
and self.fallback_url.rstrip("/") != self.base_url
|
||
)
|
||
|
||
if need_fallback:
|
||
try:
|
||
fallback_raw = self._fetch_from_url(self.fallback_url)
|
||
fallback_coverage = _field_coverage(fallback_raw)
|
||
|
||
if not _coverage_sufficient(local_coverage) and _coverage_sufficient(fallback_coverage):
|
||
raw = fallback_raw
|
||
source = "fallback"
|
||
elif _missing_precip_probability(local_coverage) and not _missing_precip_probability(fallback_coverage):
|
||
if _merge_hourly_field(raw, fallback_raw, "precipitation_probability"):
|
||
merged_fields.append("precipitation_probability")
|
||
source = "merged"
|
||
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
|
||
_cache["merged_fields"] = merged_fields
|
||
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",
|
||
"merged_fields": list(_cache.get("merged_fields") or []),
|
||
}
|
||
|
||
def _build_hourly_slice(self, raw: dict[str, Any], hours_ahead: int) -> list[dict[str, Any]]:
|
||
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))
|
||
rows: list[dict[str, Any]] = []
|
||
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")
|
||
rows.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": _conditions(code),
|
||
})
|
||
return rows
|
||
|
||
def _build_daily_slice(self, raw: dict[str, Any], days_ahead: int) -> list[dict[str, Any]]:
|
||
daily = raw.get("daily") or {}
|
||
times = daily.get("time") or []
|
||
limit = min(days_ahead, len(times))
|
||
rows: list[dict[str, Any]] = []
|
||
for i in range(limit):
|
||
code = _daily_series(daily, "weather_code")[i] if i < len(_daily_series(daily, "weather_code")) else None
|
||
rows.append({
|
||
"date": times[i],
|
||
"label": _format_day_label(times[i], i),
|
||
"temperature_max_c": _daily_series(daily, "temperature_2m_max")[i] if i < len(_daily_series(daily, "temperature_2m_max")) else None,
|
||
"temperature_min_c": _daily_series(daily, "temperature_2m_min")[i] if i < len(_daily_series(daily, "temperature_2m_min")) else None,
|
||
"precipitation_sum_mm": _daily_series(daily, "precipitation_sum")[i] if i < len(_daily_series(daily, "precipitation_sum")) else None,
|
||
"precipitation_probability_max": _daily_series(daily, "precipitation_probability_max")[i] if i < len(_daily_series(daily, "precipitation_probability_max")) else None,
|
||
"wind_speed_max_kmh": _daily_series(daily, "wind_speed_10m_max")[i] if i < len(_daily_series(daily, "wind_speed_10m_max")) else None,
|
||
"weather_code": code,
|
||
"conditions": _conditions(code),
|
||
})
|
||
return rows
|
||
|
||
def fetch_forecast(self, hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
|
||
hours_ahead = max(1, min(int(hours_ahead), 168))
|
||
days_ahead = max(1, min(int(days_ahead), self.forecast_days))
|
||
try:
|
||
raw = self._fetch_raw()
|
||
except Exception as exc:
|
||
return {"ok": False, "error": str(exc), "location": self.location_name}
|
||
|
||
current = raw.get("current") or {}
|
||
code = current.get("weather_code")
|
||
coverage = _field_coverage(raw)
|
||
local_coverage = _cache.get("local_coverage") or coverage
|
||
|
||
sync_hint = ""
|
||
if _local_needs_sync_hint(local_coverage):
|
||
sync_hint = SYNC_HINT
|
||
elif _missing_precip_probability(local_coverage):
|
||
sync_hint = PRECIP_PROB_HINT
|
||
|
||
return {
|
||
"ok": True,
|
||
"location": self.location_name,
|
||
"data_source": _cache.get("source") or "local",
|
||
"merged_fields": list(_cache.get("merged_fields") or []),
|
||
"local_field_coverage": local_coverage,
|
||
"field_coverage": coverage,
|
||
"sync_hint": sync_hint,
|
||
"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": _conditions(code),
|
||
},
|
||
"hourly": self._build_hourly_slice(raw, hours_ahead),
|
||
"daily": self._build_daily_slice(raw, days_ahead),
|
||
}
|
||
|
||
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
|
||
return self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=min(7, self.forecast_days))
|
||
|
||
def rain_summary(self, hours_ahead: int = 12, daily: list[dict[str, Any]] | None = None) -> str:
|
||
data = self.fetch_forecast(hours_ahead=hours_ahead, days_ahead=2)
|
||
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]
|
||
prob_text = f"{prob}%" if prob is not None else "—"
|
||
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
|
||
|
||
lines: list[str] = []
|
||
if rainy_hours:
|
||
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
|
||
else:
|
||
lines.append("Существенных осадков в ближайшие часы не ожидается.")
|
||
|
||
days = daily if daily is not None else data.get("daily") or []
|
||
if len(days) > 1:
|
||
tomorrow = days[1]
|
||
tmax = tomorrow.get("temperature_max_c")
|
||
tmin = tomorrow.get("temperature_min_c")
|
||
prob = tomorrow.get("precipitation_probability_max")
|
||
precip = tomorrow.get("precipitation_sum_mm") or 0
|
||
cond = tomorrow.get("conditions") or "неизвестно"
|
||
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
|
||
precip_part = f", {precip} мм" if precip > 0 else ""
|
||
lines.append(
|
||
f"Завтра: {_fmt_num(tmin)}–{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}{precip_part}."
|
||
)
|
||
return " ".join(lines)
|
||
|
||
def daily_summary(self, days_ahead: int = 7) -> str:
|
||
data = self.fetch_forecast(hours_ahead=1, days_ahead=days_ahead)
|
||
if not data.get("ok"):
|
||
return ""
|
||
parts = []
|
||
for day in data.get("daily") or []:
|
||
label = day.get("label") or day.get("date")
|
||
tmax = day.get("temperature_max_c")
|
||
tmin = day.get("temperature_min_c")
|
||
cond = day.get("conditions") or "неизвестно"
|
||
prob = day.get("precipitation_probability_max")
|
||
prob_part = f", дождь до {prob}%" if prob is not None and prob >= 30 else ""
|
||
parts.append(f"{label}: {_fmt_num(tmin)}–{_fmt_num(tmax, suffix='°C')}, {cond}{prob_part}")
|
||
return "; ".join(parts)
|
||
|
||
|
||
def format_weather_snapshot(data: dict[str, Any] | None = None, *, include_daily: bool = True) -> str:
|
||
client = OpenMeteoClient()
|
||
snapshot = data if data is not None else client.fetch_forecast(hours_ahead=6, days_ahead=3)
|
||
|
||
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}."
|
||
)
|
||
|
||
rainy_hours = []
|
||
for hour in snapshot.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]
|
||
prob_text = f"{prob}%" if prob is not None else "—"
|
||
rainy_hours.append(f"{time_str} ({prob_text}, {precip} мм)")
|
||
if rainy_hours:
|
||
lines.append("Ожидаются осадки: " + ", ".join(rainy_hours[:6]))
|
||
else:
|
||
lines.append("Существенных осадков в ближайшие часы не ожидается.")
|
||
|
||
if include_daily:
|
||
days = snapshot.get("daily") or []
|
||
if len(days) > 1:
|
||
tomorrow = days[1]
|
||
lines.append(
|
||
f"Завтра: {_fmt_num(tomorrow.get('temperature_min_c'))}–"
|
||
f"{_fmt_num(tomorrow.get('temperature_max_c'), suffix='°C')}, "
|
||
f"{tomorrow.get('conditions') or 'неизвестно'}."
|
||
)
|
||
if len(days) > 2:
|
||
week_bits = []
|
||
for day in days[2:7]:
|
||
week_bits.append(
|
||
f"{day.get('label')}: {_fmt_num(day.get('temperature_min_c'))}–"
|
||
f"{_fmt_num(day.get('temperature_max_c'), suffix='°C')}"
|
||
)
|
||
if week_bits:
|
||
lines.append("Далее: " + "; ".join(week_bits) + ".")
|
||
|
||
lines.append("Подробнее — get_weather (hours_ahead, days_ahead).")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def build_weather_dashboard(hours_ahead: int = 12, days_ahead: int = 7) -> dict[str, Any]:
|
||
client = OpenMeteoClient()
|
||
weather = client.fetch_forecast(hours_ahead=hours_ahead, days_ahead=days_ahead)
|
||
return {
|
||
"weather": weather,
|
||
"rain_summary": client.rain_summary(hours_ahead=hours_ahead, daily=weather.get("daily")) if weather.get("ok") else "",
|
||
"daily_summary": client.daily_summary(days_ahead=days_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": client.forecast_days,
|
||
"timezone": "auto",
|
||
},
|
||
"available_fields": {
|
||
"current": list(CURRENT_FIELDS),
|
||
"hourly": list(HOURLY_FIELDS),
|
||
"daily": list(DAILY_FIELDS),
|
||
},
|
||
"field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
|
||
"local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": [], "daily": []},
|
||
"data_source": weather.get("data_source", "local") if weather.get("ok") else "local",
|
||
"merged_fields": weather.get("merged_fields", []) if weather.get("ok") else [],
|
||
"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 до 168) + по дням (days_ahead до 16)",
|
||
"get_morning_briefing": "Погода + заголовки RSS-новостей",
|
||
},
|
||
"system_prompt": "Блок [Погода] в system prompt — только если запрос про погоду/одежду/прогноз.",
|
||
}
|