feat: refine assistant logic and update docs

This commit is contained in:
future
2026-04-09 21:03:02 +03:00
parent ebe79c3692
commit 42c064a274
19 changed files with 1958 additions and 492 deletions

View File

@@ -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."
)
# Глобальный экземпляр

View File

@@ -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

View File

@@ -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: