feat: refine assistant logic and update docs
This commit is contained in:
@@ -54,6 +54,16 @@ _PARTS_OF_DAY = {"утра", "дня", "вечера", "ночи"}
|
||||
_FILLER_WORDS = {"мне", "меня", "пожалуйста", "на", "в", "во", "к", "и"}
|
||||
_HOUR_WORDS = {"час", "часа", "часов"}
|
||||
_MINUTE_WORDS = {"минута", "минуту", "минуты", "минут"}
|
||||
_ALARM_MARKERS = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"}
|
||||
_ALARM_LIST_RE = re.compile(
|
||||
r"\b(какие|какой|список|активн|покажи|показать|сколько|есть ли|перечисли)\b"
|
||||
)
|
||||
_ALARM_CANCEL_RE = re.compile(
|
||||
r"\b(отмени|отмена|удали|удалить|выключи|отключи|деактивир|сбрось|очисти)\b"
|
||||
)
|
||||
_ALARM_CREATE_RE = re.compile(
|
||||
r"\b(постав|установ|запусти|включи|разбуди|создай|добавь|измени|перенес|назнач)\b"
|
||||
)
|
||||
|
||||
|
||||
def _parse_number_tokens(tokens, start_index: int):
|
||||
@@ -97,10 +107,9 @@ def _apply_part_of_day(hour: int, part_of_day: str | None) -> int:
|
||||
|
||||
def _extract_alarm_time_words(text: str):
|
||||
tokens = re.findall(r"[a-zа-я0-9]+", text.lower().replace("ё", "е"))
|
||||
markers = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"}
|
||||
|
||||
for index, token in enumerate(tokens):
|
||||
if token not in markers:
|
||||
if token not in _ALARM_MARKERS:
|
||||
continue
|
||||
|
||||
current = index + 1
|
||||
@@ -134,6 +143,40 @@ def _extract_alarm_time_words(text: str):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_alarm_time(text: str):
|
||||
# Формат "7:30", "7.30", "7-30" и варианты с "в/на/к".
|
||||
match = re.search(r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})[:.-](\d{2})\b", text)
|
||||
if match:
|
||||
h, m = int(match.group(1)), int(match.group(2))
|
||||
period_match = re.search(
|
||||
r"\b(?:на|в|во|к)?\s*"
|
||||
+ re.escape(match.group(0).strip())
|
||||
+ r"\s+(утра|дня|вечера|ночи)\b",
|
||||
text,
|
||||
)
|
||||
part_of_day = period_match.group(1) if period_match else None
|
||||
h = _apply_part_of_day(h, part_of_day)
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
return h, m
|
||||
|
||||
# Формат цифрами: "в 7 утра", "на 7", "к 6 30".
|
||||
match_time = re.search(
|
||||
r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})(?:\s*(?:часов|часа|час))?"
|
||||
r"(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?"
|
||||
r"(?:\s+(утра|дня|вечера|ночи))?\b",
|
||||
text,
|
||||
)
|
||||
if match_time:
|
||||
h = int(match_time.group(1))
|
||||
m = int(match_time.group(2)) if match_time.group(2) else 0
|
||||
h = _apply_part_of_day(h, match_time.group(3))
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
return h, m
|
||||
|
||||
# Формат словами: "в семь утра", "будильник семь тридцать".
|
||||
return _extract_alarm_time_words(text)
|
||||
|
||||
|
||||
class AlarmClock:
|
||||
def __init__(self):
|
||||
self.alarms = []
|
||||
@@ -229,7 +272,14 @@ class AlarmClock:
|
||||
return self.add_alarm_with_days(hour, minute, days=None)
|
||||
|
||||
def add_alarm_with_days(self, hour: int, minute: int, days=None):
|
||||
"""Добавление нового будильника (или обновление существующего) с днями недели."""
|
||||
"""
|
||||
Добавление нового будильника (или обновление существующего) с днями недели.
|
||||
|
||||
Returns:
|
||||
"created" - создан новый будильник
|
||||
"reactivated" - найден существующий неактивный, включён обратно
|
||||
"already_active" - такой будильник уже активен
|
||||
"""
|
||||
days_key = self._days_key(days)
|
||||
for alarm in self.alarms:
|
||||
if (
|
||||
@@ -237,11 +287,13 @@ class AlarmClock:
|
||||
and alarm.get("minute") == minute
|
||||
and self._days_key(alarm.get("days")) == days_key
|
||||
):
|
||||
if alarm.get("active"):
|
||||
return "already_active"
|
||||
alarm["active"] = True
|
||||
alarm["days"] = days_key
|
||||
alarm["last_triggered"] = None
|
||||
self.save_alarms()
|
||||
return
|
||||
return "reactivated"
|
||||
|
||||
self.alarms.append(
|
||||
{"hour": hour, "minute": minute, "active": True, "days": days_key}
|
||||
@@ -250,6 +302,7 @@ class AlarmClock:
|
||||
days_phrase = self._format_days_phrase(days_key)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
|
||||
return "created"
|
||||
|
||||
def cancel_all_alarms(self):
|
||||
"""Выключение (деактивация) всех будильников."""
|
||||
@@ -258,6 +311,33 @@ class AlarmClock:
|
||||
self.save_alarms()
|
||||
print("🔕 Все будильники отменены.")
|
||||
|
||||
def remove_alarms(self, hour: int, minute: int, days=None) -> int:
|
||||
"""
|
||||
Удаляет будильники по времени.
|
||||
Если переданы days, удаляются только будильники с совпадающими днями.
|
||||
"""
|
||||
days_key = self._days_key(days)
|
||||
kept = []
|
||||
removed = 0
|
||||
|
||||
for alarm in self.alarms:
|
||||
alarm_hour = alarm.get("hour")
|
||||
alarm_minute = alarm.get("minute")
|
||||
if alarm_hour != hour or alarm_minute != minute:
|
||||
kept.append(alarm)
|
||||
continue
|
||||
|
||||
if days_key is not None and self._days_key(alarm.get("days")) != days_key:
|
||||
kept.append(alarm)
|
||||
continue
|
||||
|
||||
removed += 1
|
||||
|
||||
if removed:
|
||||
self.alarms = kept
|
||||
self.save_alarms()
|
||||
return removed
|
||||
|
||||
def describe_alarms(self) -> str:
|
||||
"""Возвращает текстовое описание активных будильников."""
|
||||
active = [
|
||||
@@ -365,73 +445,60 @@ class AlarmClock:
|
||||
|
||||
def parse_command(self, text: str) -> str | None:
|
||||
"""
|
||||
Парсинг команды установки будильника из текста.
|
||||
Примеры: "разбуди в 7:30", "будильник на 8 утра".
|
||||
Парсинг команд управления будильниками.
|
||||
Примеры: "разбуди в 7:30", "удали будильник на 8:00", "какие будильники".
|
||||
"""
|
||||
text = replace_roman_numerals(text.lower())
|
||||
if "будильник" not in text and "разбуди" not in text:
|
||||
text = replace_roman_numerals(text.lower().replace("ё", "е"))
|
||||
if not re.search(r"\b(будильник\w*|разбуд\w*)\b", text):
|
||||
return None
|
||||
|
||||
if "будильник" in text and re.search(
|
||||
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
|
||||
):
|
||||
if _ALARM_LIST_RE.search(text):
|
||||
return self.describe_alarms()
|
||||
|
||||
if "отмени" in text:
|
||||
self.cancel_all_alarms()
|
||||
return "Хорошо, я отменил все будильники."
|
||||
if _ALARM_CANCEL_RE.search(text):
|
||||
cancel_time = _extract_alarm_time(text)
|
||||
cancel_days = self._extract_alarm_days(text)
|
||||
if cancel_time:
|
||||
h, m = cancel_time
|
||||
removed = self.remove_alarms(h, m, days=cancel_days)
|
||||
if removed:
|
||||
days_phrase = self._format_days_phrase(cancel_days)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
return f"Удалил {removed} будильник(а) на {h:02d}:{m:02d}{suffix}."
|
||||
return f"Не нашел будильник на {h:02d}:{m:02d}."
|
||||
|
||||
if re.search(r"\b(все|всех)\b", text) or "будильники" in text:
|
||||
self.cancel_all_alarms()
|
||||
return "Хорошо, я отменил все будильники."
|
||||
|
||||
return (
|
||||
"Скажите время будильника, который нужно удалить. "
|
||||
"Например: удалите будильник на 7:30."
|
||||
)
|
||||
|
||||
days = self._extract_alarm_days(text)
|
||||
|
||||
# Поиск формата "7:30", "7.30" и вариантов с "в/на/к".
|
||||
match = re.search(r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})[:.-](\d{2})\b", text)
|
||||
if match:
|
||||
h, m = int(match.group(1)), int(match.group(2))
|
||||
period_match = re.search(
|
||||
r"\b(?:на|в|во|к)?\s*" + re.escape(match.group(0).strip()) + r"\s+(утра|дня|вечера|ночи)\b",
|
||||
text,
|
||||
)
|
||||
part_of_day = period_match.group(1) if period_match else None
|
||||
h = _apply_part_of_day(h, part_of_day)
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
self.add_alarm_with_days(h, m, days=days)
|
||||
days_phrase = self._format_days_phrase(days)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
return f"Я установил будильник на {h} часов {m} минут{suffix}."
|
||||
|
||||
# Поиск формата цифрами: "в 7 утра", "на 7", "к 6 30"
|
||||
match_time = re.search(
|
||||
r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?(?:\s+(утра|дня|вечера|ночи))?\b",
|
||||
text,
|
||||
)
|
||||
|
||||
if match_time:
|
||||
h = int(match_time.group(1))
|
||||
m = int(match_time.group(2)) if match_time.group(2) else 0
|
||||
h = _apply_part_of_day(h, match_time.group(3))
|
||||
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
self.add_alarm_with_days(h, m, days=days)
|
||||
days_phrase = self._format_days_phrase(days)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||
|
||||
# Поиск формата словами: "в семь утра", "будильник семь тридцать"
|
||||
word_time = _extract_alarm_time_words(text)
|
||||
if word_time:
|
||||
h, m = word_time
|
||||
self.add_alarm_with_days(h, m, days=days)
|
||||
alarm_time = _extract_alarm_time(text)
|
||||
if alarm_time:
|
||||
h, m = alarm_time
|
||||
add_status = self.add_alarm_with_days(h, m, days=days)
|
||||
if add_status == "already_active":
|
||||
return "Такой будильник уже установлен."
|
||||
days_phrase = self._format_days_phrase(days)
|
||||
suffix = f" {days_phrase}" if days_phrase else ""
|
||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||
|
||||
if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in {
|
||||
if _ALARM_CREATE_RE.search(text) or text.strip() in {
|
||||
"будильник",
|
||||
"поставь будильник",
|
||||
"создай будильник",
|
||||
"добавь будильник",
|
||||
}:
|
||||
return ASK_ALARM_TIME_PROMPT
|
||||
|
||||
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
||||
return (
|
||||
"Я не понял команду для будильника. "
|
||||
"Скажите, например: поставь на 7:30, покажи будильники или удали будильник на 7:30."
|
||||
)
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
|
||||
@@ -20,7 +20,7 @@ from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from ..core.config import BASE_DIR, WAKE_WORD_ALIASES
|
||||
from ..core.config import BASE_DIR, WAKE_WORD_ALIASES, WAKEWORD_MUSIC_DUCK_RATIO
|
||||
|
||||
try:
|
||||
import spotipy
|
||||
@@ -97,6 +97,8 @@ class SpotifyProvider:
|
||||
self.sp = None
|
||||
self._initialized = False
|
||||
self._init_error = None
|
||||
self._duck_prev_volume: Optional[int] = None
|
||||
self._duck_active = False
|
||||
|
||||
def initialize(self) -> bool:
|
||||
if self._initialized:
|
||||
@@ -249,6 +251,71 @@ class SpotifyProvider:
|
||||
except Exception as exc:
|
||||
return f"Spotify: {exc}"
|
||||
|
||||
def duck_volume(self, ratio: float) -> bool:
|
||||
if self._duck_active:
|
||||
return True
|
||||
if not self._ensure_initialized():
|
||||
return False
|
||||
|
||||
try:
|
||||
current = self.sp.current_playback()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not current or not current.get("is_playing"):
|
||||
return False
|
||||
|
||||
device = current.get("device") or {}
|
||||
device_id = device.get("id")
|
||||
volume_percent = device.get("volume_percent")
|
||||
if volume_percent is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_volume = int(volume_percent)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
target_volume = int(round(current_volume * float(ratio)))
|
||||
target_volume = max(0, min(100, target_volume))
|
||||
if target_volume >= current_volume:
|
||||
target_volume = max(0, current_volume - 1)
|
||||
|
||||
try:
|
||||
self.sp.volume(target_volume, device_id=device_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
self._duck_prev_volume = current_volume
|
||||
self._duck_active = True
|
||||
return True
|
||||
|
||||
def restore_duck_volume(self) -> bool:
|
||||
if not self._duck_active:
|
||||
return False
|
||||
|
||||
previous_volume = self._duck_prev_volume
|
||||
self._duck_prev_volume = None
|
||||
self._duck_active = False
|
||||
|
||||
if previous_volume is None:
|
||||
return False
|
||||
if not self._ensure_initialized():
|
||||
return False
|
||||
|
||||
try:
|
||||
current = self.sp.current_playback() or {}
|
||||
device = current.get("device") or {}
|
||||
device_id = device.get("id")
|
||||
self.sp.volume(int(max(0, min(100, previous_volume))), device_id=device_id)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def clear_duck_state(self) -> None:
|
||||
self._duck_prev_volume = None
|
||||
self._duck_active = False
|
||||
|
||||
|
||||
class NavidromeProvider:
|
||||
"""Primary provider using Navidrome + MPV IPC."""
|
||||
@@ -269,6 +336,8 @@ class NavidromeProvider:
|
||||
self._folder_index_built_at = 0.0
|
||||
self._autonext_lock = threading.Lock()
|
||||
self._autonext_suppress_until = 0.0
|
||||
self._duck_prev_volume: Optional[float] = None
|
||||
self._duck_active = False
|
||||
|
||||
self._snapshot_stop = threading.Event()
|
||||
self._snapshot_thread = threading.Thread(
|
||||
@@ -764,6 +833,53 @@ class NavidromeProvider:
|
||||
|
||||
self._set_state(paused=paused, position_sec=max(0.0, position))
|
||||
|
||||
def duck_volume(self, ratio: float) -> bool:
|
||||
if self._duck_active:
|
||||
return True
|
||||
if not self._is_mpv_alive():
|
||||
return False
|
||||
|
||||
try:
|
||||
paused = bool(self._mpv_ipc(["get_property", "pause"]))
|
||||
if paused:
|
||||
return False
|
||||
current_volume = float(self._mpv_ipc(["get_property", "volume"]) or 100.0)
|
||||
target_volume = max(0.0, min(100.0, current_volume * float(ratio)))
|
||||
if target_volume >= current_volume:
|
||||
target_volume = max(0.0, current_volume - 1.0)
|
||||
self._mpv_ipc(["set_property", "volume", target_volume])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
self._duck_prev_volume = current_volume
|
||||
self._duck_active = True
|
||||
return True
|
||||
|
||||
def restore_duck_volume(self) -> bool:
|
||||
if not self._duck_active:
|
||||
return False
|
||||
|
||||
previous_volume = self._duck_prev_volume
|
||||
self._duck_prev_volume = None
|
||||
self._duck_active = False
|
||||
|
||||
if previous_volume is None:
|
||||
return False
|
||||
if not self._is_mpv_alive():
|
||||
return False
|
||||
|
||||
try:
|
||||
self._mpv_ipc(
|
||||
["set_property", "volume", max(0.0, min(100.0, float(previous_volume)))]
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def clear_duck_state(self) -> None:
|
||||
self._duck_prev_volume = None
|
||||
self._duck_active = False
|
||||
|
||||
def _snapshot_loop(self) -> None:
|
||||
while not self._snapshot_stop.wait(1.0):
|
||||
try:
|
||||
@@ -793,6 +909,7 @@ class NavidromeProvider:
|
||||
self._mpv_process = None
|
||||
|
||||
self._remove_stale_socket()
|
||||
self.clear_duck_state()
|
||||
|
||||
def _start_song(self, song: Song, start_sec: float = 0.0) -> None:
|
||||
self._ensure_initialized()
|
||||
@@ -1024,6 +1141,8 @@ class MusicController:
|
||||
def __init__(self) -> None:
|
||||
self.navidrome = NavidromeProvider()
|
||||
self.spotify = SpotifyProvider()
|
||||
self._wakeword_duck_ratio = max(0.01, min(1.0, float(WAKEWORD_MUSIC_DUCK_RATIO)))
|
||||
self._duck_active_provider: Optional[str] = None
|
||||
aliases = sorted(
|
||||
{
|
||||
alias.lower().replace("ё", "е").strip()
|
||||
@@ -1058,6 +1177,41 @@ class MusicController:
|
||||
f" (Причина: {exc})"
|
||||
)
|
||||
|
||||
def _play_default(self) -> str:
|
||||
"""Default voice command ("включи музыку") with provider fallback."""
|
||||
return self._with_fallback(
|
||||
lambda: self.navidrome.play_random(contextual_resume=True),
|
||||
lambda: self.spotify.play_music(),
|
||||
)
|
||||
|
||||
def duck_for_wakeword(self) -> bool:
|
||||
if self._duck_active_provider in {"navidrome", "spotify"}:
|
||||
return True
|
||||
if self._wakeword_duck_ratio >= 1.0:
|
||||
return False
|
||||
|
||||
if self.navidrome.duck_volume(self._wakeword_duck_ratio):
|
||||
self._duck_active_provider = "navidrome"
|
||||
return True
|
||||
if self.spotify.duck_volume(self._wakeword_duck_ratio):
|
||||
self._duck_active_provider = "spotify"
|
||||
return True
|
||||
return False
|
||||
|
||||
def restore_after_wakeword(self) -> bool:
|
||||
provider = self._duck_active_provider
|
||||
self._duck_active_provider = None
|
||||
|
||||
if provider == "navidrome":
|
||||
return self.navidrome.restore_duck_volume()
|
||||
if provider == "spotify":
|
||||
return self.spotify.restore_duck_volume()
|
||||
|
||||
# Защитная очистка на случай рассинхронизации состояния.
|
||||
self.navidrome.clear_duck_state()
|
||||
self.spotify.clear_duck_state()
|
||||
return False
|
||||
|
||||
def pause_for_stop_word(self) -> Optional[str]:
|
||||
"""
|
||||
Pause music for generic stop-words ("стоп", "хватит", etc).
|
||||
@@ -1187,10 +1341,7 @@ class MusicController:
|
||||
lambda: self.navidrome.play_query(normalized_query),
|
||||
lambda: self.spotify.play_music(normalized_query),
|
||||
)
|
||||
return self._with_fallback(
|
||||
lambda: self.navidrome.play_random(contextual_resume=True),
|
||||
lambda: self.spotify.play_music(None),
|
||||
)
|
||||
return self._play_default()
|
||||
|
||||
if normalized_action in {"play_query", "search"}:
|
||||
if not normalized_query:
|
||||
@@ -1286,10 +1437,7 @@ class MusicController:
|
||||
lambda: self.navidrome.play_query(play_query),
|
||||
lambda: self.spotify.play_music(play_query),
|
||||
)
|
||||
return self._with_fallback(
|
||||
lambda: self.navidrome.play_random(contextual_resume=True),
|
||||
lambda: self.spotify.play_music(None),
|
||||
)
|
||||
return self._play_default()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -3,11 +3,120 @@ Weather feature module.
|
||||
Fetches weather data from Open-Meteo API.
|
||||
"""
|
||||
|
||||
import re
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
|
||||
|
||||
_HTTP = requests.Session()
|
||||
_CITY_PREFIX_RE = re.compile(
|
||||
r"^(?:в|во)\s+(?:город(?:е|у)?\s+)?",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_CITY_SPACING_RE = re.compile(r"\s+")
|
||||
_KNOWN_CITY_VARIATIONS = {
|
||||
"нью йорк": "Нью-Йорк",
|
||||
"нью-йорк": "Нью-Йорк",
|
||||
"нью йорке": "Нью-Йорк",
|
||||
"нью-йорке": "Нью-Йорк",
|
||||
"нью йорка": "Нью-Йорк",
|
||||
"нью-йорка": "Нью-Йорк",
|
||||
"нью йорком": "Нью-Йорк",
|
||||
"нью-йорком": "Нью-Йорк",
|
||||
"санкт петербург": "Санкт-Петербург",
|
||||
"санкт-петербург": "Санкт-Петербург",
|
||||
"санкт петербурге": "Санкт-Петербург",
|
||||
"санкт-петербурге": "Санкт-Петербург",
|
||||
"санкт петербурга": "Санкт-Петербург",
|
||||
"санкт-петербурга": "Санкт-Петербург",
|
||||
"санкт петербургом": "Санкт-Петербург",
|
||||
"санкт-петербургом": "Санкт-Петербург",
|
||||
"нижний новгород": "Нижний Новгород",
|
||||
"нижнем новгороде": "Нижний Новгород",
|
||||
"нижнего новгорода": "Нижний Новгород",
|
||||
"ростов на дону": "Ростов-на-Дону",
|
||||
"ростове на дону": "Ростов-на-Дону",
|
||||
"ростова на дону": "Ростов-на-Дону",
|
||||
"лос анджелес": "Лос-Анджелес",
|
||||
"лос-анджелес": "Лос-Анджелес",
|
||||
"лос анджелесе": "Лос-Анджелес",
|
||||
"лос-анджелесе": "Лос-Анджелес",
|
||||
"сан франциско": "Сан-Франциско",
|
||||
"сан-франциско": "Сан-Франциско",
|
||||
"улан удэ": "Улан-Удэ",
|
||||
"улан-удэ": "Улан-Удэ",
|
||||
}
|
||||
_SINGLE_WORD_CITY_VARIATIONS = {
|
||||
"москве": "Москва",
|
||||
"москвы": "Москва",
|
||||
"москвой": "Москва",
|
||||
"москву": "Москва",
|
||||
"лондоне": "Лондон",
|
||||
"лондона": "Лондон",
|
||||
"лондоном": "Лондон",
|
||||
"париже": "Париж",
|
||||
"парижа": "Париж",
|
||||
"парижем": "Париж",
|
||||
"берлине": "Берлин",
|
||||
"берлина": "Берлин",
|
||||
"берлином": "Берлин",
|
||||
"пекине": "Пекин",
|
||||
"пекина": "Пекин",
|
||||
"пекином": "Пекин",
|
||||
"роме": "Рим",
|
||||
"рима": "Рим",
|
||||
"римом": "Рим",
|
||||
"мадриде": "Мадрид",
|
||||
"мадрида": "Мадрид",
|
||||
"мадридом": "Мадрид",
|
||||
"сиднее": "Сидней",
|
||||
"сиднея": "Сидней",
|
||||
"сиднеем": "Сидней",
|
||||
"вашингтоне": "Вашингтон",
|
||||
"вашингтона": "Вашингтон",
|
||||
"вашингтоном": "Вашингтон",
|
||||
"сиэтле": "Сиэтл",
|
||||
"сиэтла": "Сиэтл",
|
||||
"сиэтлом": "Сиэтл",
|
||||
"бостоне": "Бостон",
|
||||
"бостона": "Бостон",
|
||||
"бостоном": "Бостон",
|
||||
"денвере": "Денвер",
|
||||
"денвера": "Денвер",
|
||||
"денвером": "Денвер",
|
||||
"хьюстоне": "Хьюстон",
|
||||
"хьюстона": "Хьюстон",
|
||||
"хьюстоном": "Хьюстон",
|
||||
"фениксе": "Феникс",
|
||||
"феникса": "Феникс",
|
||||
"фениксом": "Феникс",
|
||||
"атланте": "Атланта",
|
||||
"атланты": "Атланта",
|
||||
"атлантой": "Атланта",
|
||||
"портленде": "Портленд",
|
||||
"портленда": "Портленд",
|
||||
"портлендом": "Портленд",
|
||||
"остине": "Остин",
|
||||
"остина": "Остин",
|
||||
"остином": "Остин",
|
||||
"нэшвилле": "Нэшвилл",
|
||||
"нэшвилла": "Нэшвилл",
|
||||
"нэшвиллом": "Нэшвилл",
|
||||
"токио": "Токио",
|
||||
"торонто": "Торонто",
|
||||
"чикаго": "Чикаго",
|
||||
"майами": "Майами",
|
||||
}
|
||||
|
||||
|
||||
def _smart_title_city(text: str) -> str:
|
||||
parts = []
|
||||
for word in text.split():
|
||||
hyphen_parts = [part.capitalize() for part in word.split("-") if part]
|
||||
parts.append("-".join(hyphen_parts))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def get_wmo_description(code: int) -> str:
|
||||
"""Decodes WMO weather code to Russian description."""
|
||||
codes = {
|
||||
@@ -72,143 +181,45 @@ def normalize_city_name(city_name: str) -> str:
|
||||
Converts city names from various grammatical cases to the base form for geocoding.
|
||||
Handles common Russian grammatical cases (падежи) for city names.
|
||||
"""
|
||||
# Convert to lowercase for comparison
|
||||
lower_city = city_name.lower()
|
||||
|
||||
# Remove common Russian location descriptors that might be included by mistake
|
||||
# For example, if someone says "в городе Волгоград", the city_name might be "городе волгоград"
|
||||
# So we want to extract just "волгоград"
|
||||
if 'городе' in lower_city:
|
||||
# Extract the part after "городе"
|
||||
parts = lower_city.split('городе')
|
||||
if len(parts) > 1:
|
||||
lower_city = parts[1].strip()
|
||||
elif 'город' in lower_city:
|
||||
# Extract the part after "город"
|
||||
parts = lower_city.split('город')
|
||||
if len(parts) > 1:
|
||||
lower_city = parts[1].strip()
|
||||
|
||||
# Common endings for different cases in Russian
|
||||
# Prepositional case endings (-е, -и, -у, etc.)
|
||||
prepositional_endings = ['е', 'и', 'у', 'о', 'й']
|
||||
genitive_endings = ['а', 'я', 'ов', 'ев', 'ин', 'ын']
|
||||
instrumental_endings = ['ом', 'ем', 'ой', 'ей']
|
||||
|
||||
# If the city ends with a prepositional ending, try removing it to get the base form
|
||||
if lower_city.endswith(tuple(prepositional_endings)):
|
||||
# Try to remove the ending and see if we get a valid base form
|
||||
base_form = lower_city
|
||||
# Try removing 1-2 characters to get the base form
|
||||
for i in range(2, 0, -1): # Try removing 2 chars, then 1 char
|
||||
if len(base_form) > i:
|
||||
potential_base = base_form[:-i]
|
||||
# Check if the removed part is a common ending
|
||||
if base_form[-i:] in ['ке', 'ме', 'не', 'ве', 'ге', 'де', 'те']:
|
||||
base_form = potential_base
|
||||
break
|
||||
elif base_form[-1] in prepositional_endings:
|
||||
base_form = base_form[:-1]
|
||||
break
|
||||
|
||||
# Special handling for common patterns
|
||||
if base_form.endswith('йорке'): # "нью-йорке" -> "нью-йорк"
|
||||
base_form = base_form[:-1] + 'к'
|
||||
elif base_form.endswith('ске'): # "москве" -> "москва", "париже" -> "париж"
|
||||
# This is more complex, but for "москве" -> "москва", "париже" -> "париж"
|
||||
# We'll handle the most common cases
|
||||
if base_form == 'москве':
|
||||
base_form = 'москва'
|
||||
elif base_form == 'париже':
|
||||
base_form = 'париж'
|
||||
elif base_form == 'лондоне':
|
||||
base_form = 'лондон'
|
||||
elif base_form == 'берлине':
|
||||
base_form = 'берлин'
|
||||
elif base_form == 'токио': # токио stays токио
|
||||
base_form = 'токио'
|
||||
else:
|
||||
# General rule: replace -е with -а or -ь
|
||||
if base_form.endswith('ске'):
|
||||
base_form = base_form[:-1] + 'а'
|
||||
elif base_form.endswith('ие'):
|
||||
base_form = base_form[:-2] + 'ия'
|
||||
|
||||
# Capitalize appropriately
|
||||
if base_form != lower_city:
|
||||
return base_form.capitalize()
|
||||
|
||||
# Dictionary mapping specific known variations
|
||||
case_variations = {
|
||||
"нью-йорке": "Нью-Йорк",
|
||||
"нью-йорка": "Нью-Йорк",
|
||||
"нью-йорком": "Нью-Йорк",
|
||||
"москве": "Москва",
|
||||
"москвы": "Москва",
|
||||
"москвой": "Москва",
|
||||
"москву": "Москва",
|
||||
"лондоне": "Лондон",
|
||||
"лондона": "Лондон",
|
||||
"лондоном": "Лондон",
|
||||
"париже": "Париж",
|
||||
"парижа": "Париж",
|
||||
"парижем": "Париж",
|
||||
"берлине": "Берлин",
|
||||
"берлина": "Берлин",
|
||||
"берлином": "Берлин",
|
||||
"пекине": "Пекин",
|
||||
"пекина": "Пекин",
|
||||
"пекином": "Пекин",
|
||||
"роме": "Рим",
|
||||
"рима": "Рим",
|
||||
"римом": "Рим",
|
||||
"мадриде": "Мадрид",
|
||||
"мадрида": "Мадрид",
|
||||
"мадридом": "Мадрид",
|
||||
"сиднее": "Сидней",
|
||||
"сиднея": "Сидней",
|
||||
"сиднеем": "Сидней",
|
||||
"вашингтоне": "Вашингтон",
|
||||
"вашингтона": "Вашингтон",
|
||||
"вашингтоном": "Вашингтон",
|
||||
"лос-анджелесе": "Лос-Анджелес",
|
||||
"лос-анджелеса": "Лос-Анджелес",
|
||||
"лос-анджелесом": "Лос-Анджелес",
|
||||
"сиэтле": "Сиэтл",
|
||||
"сиэтла": "Сиэтл",
|
||||
"сиэтлом": "Сиэтл",
|
||||
"бостоне": "Бостон",
|
||||
"бостона": "Бостон",
|
||||
"бостоном": "Бостон",
|
||||
"денвере": "Денвер",
|
||||
"денвера": "Денвер",
|
||||
"денвером": "Денвер",
|
||||
"хьюстоне": "Хьюстон",
|
||||
"хьюстона": "Хьюстон",
|
||||
"хьюстоном": "Хьюстон",
|
||||
"фениксе": "Феникс",
|
||||
"феникса": "Феникс",
|
||||
"фениксом": "Феникс",
|
||||
"атланте": "Атланта",
|
||||
"атланты": "Атланта",
|
||||
"атлантой": "Атланта",
|
||||
"портленде": "Портленд",
|
||||
"портленда": "Портленд",
|
||||
"портлендом": "Портленд",
|
||||
"остине": "Остин",
|
||||
"остина": "Остин",
|
||||
"остином": "Остин",
|
||||
"нэшвилле": "Нэшвилл",
|
||||
"нэшвилла": "Нэшвилл",
|
||||
"нэшвиллом": "Нэшвилл",
|
||||
"сан-франциско": "Сан-Франциско",
|
||||
"токио": "Токио",
|
||||
"торонто": "Торонто",
|
||||
"чикаго": "Чикаго",
|
||||
"майами": "Майами",
|
||||
}
|
||||
|
||||
return case_variations.get(lower_city, city_name)
|
||||
lowered = str(city_name or "").lower().replace("ё", "е").strip()
|
||||
if not lowered:
|
||||
return city_name
|
||||
|
||||
lowered = _CITY_PREFIX_RE.sub("", lowered)
|
||||
lowered = _CITY_SPACING_RE.sub(" ", lowered).strip(" -")
|
||||
if not lowered:
|
||||
return city_name
|
||||
|
||||
exact_match = _KNOWN_CITY_VARIATIONS.get(lowered)
|
||||
if exact_match:
|
||||
return exact_match
|
||||
|
||||
single_word_match = _SINGLE_WORD_CITY_VARIATIONS.get(lowered)
|
||||
if single_word_match:
|
||||
return single_word_match
|
||||
|
||||
spaced = lowered.replace("-", " ")
|
||||
exact_match = _KNOWN_CITY_VARIATIONS.get(spaced)
|
||||
if exact_match:
|
||||
return exact_match
|
||||
|
||||
if " " not in spaced:
|
||||
for suffix, replacement in (
|
||||
("ом", ""),
|
||||
("ем", ""),
|
||||
("ой", "а"),
|
||||
("ей", "а"),
|
||||
("е", ""),
|
||||
("у", "а"),
|
||||
("ю", "я"),
|
||||
):
|
||||
if spaced.endswith(suffix) and len(spaced) > len(suffix) + 2:
|
||||
candidate = spaced[: -len(suffix)] + replacement
|
||||
mapped = _SINGLE_WORD_CITY_VARIATIONS.get(candidate)
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
return _smart_title_city(lowered)
|
||||
|
||||
def get_coordinates_by_city(city_name: str) -> tuple:
|
||||
"""
|
||||
@@ -220,8 +231,9 @@ def get_coordinates_by_city(city_name: str) -> tuple:
|
||||
|
||||
# Add normalized version
|
||||
normalized_city = normalize_city_name(city_name)
|
||||
if normalized_city != city_name:
|
||||
if normalized_city and normalized_city not in try_names:
|
||||
try_names.append(normalized_city)
|
||||
normalized_lower = str(normalized_city or city_name).lower().replace("ё", "е").strip()
|
||||
|
||||
# Also try with English version if it's a known translation
|
||||
city_to_eng = {
|
||||
@@ -334,8 +346,18 @@ def get_coordinates_by_city(city_name: str) -> tuple:
|
||||
}
|
||||
|
||||
eng_name = city_to_eng.get(city_name.lower())
|
||||
if eng_name:
|
||||
normalized_eng_name = city_to_eng.get(normalized_lower)
|
||||
if eng_name and eng_name not in try_names:
|
||||
try_names.append(eng_name)
|
||||
if normalized_eng_name and normalized_eng_name not in try_names:
|
||||
try_names.append(normalized_eng_name)
|
||||
|
||||
if normalized_city:
|
||||
hyphen_variant = normalized_city.replace(" ", "-")
|
||||
space_variant = normalized_city.replace("-", " ")
|
||||
for variant in (hyphen_variant, space_variant):
|
||||
if variant and variant not in try_names:
|
||||
try_names.append(variant)
|
||||
|
||||
# Try each name in sequence
|
||||
for name_to_try in try_names:
|
||||
|
||||
Reference in New Issue
Block a user