From 38b95e0de657617a56650d4c7221ce730933dbb8 Mon Sep 17 00:00:00 2001 From: future Date: Mon, 2 Feb 2026 23:40:36 +0300 Subject: [PATCH] Add cities game to avoid rambling responses --- app/features/cities_game.py | 325 ++++++++++++++++++++++++++++++++++++ app/main.py | 11 ++ 2 files changed, 336 insertions(+) create mode 100644 app/features/cities_game.py diff --git a/app/features/cities_game.py b/app/features/cities_game.py new file mode 100644 index 0000000..3240ae3 --- /dev/null +++ b/app/features/cities_game.py @@ -0,0 +1,325 @@ +""" +Cities game feature (RU). +Plays the classic "города" game: each next city starts with the last valid letter. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Optional + + +_EXCLUDED_LAST_LETTERS = {"ь", "ъ", "ы", "й"} +_CITY_TOKEN_RE = re.compile(r"[a-zа-яё]+", re.IGNORECASE) +_NON_CITY_TOKENS = {"город", "города", "игра", "игру", "сыграем", "сыграть"} + +# Core city list (RU + a few common international cities in RU spelling). +# Keep it curated and deterministic to avoid "rambling" responses. +_CITIES = [ + "Абакан", + "Анапа", + "Апатиты", + "Архангельск", + "Астрахань", + "Барнаул", + "Белгород", + "Бердск", + "Бийск", + "Благовещенск", + "Брянск", + "Великий Новгород", + "Великий Устюг", + "Владивосток", + "Владикавказ", + "Владимир", + "Волгоград", + "Волгодонск", + "Вологда", + "Воронеж", + "Выборг", + "Гатчина", + "Геленджик", + "Екатеринбург", + "Елабуга", + "Ессентуки", + "Жуковский", + "Зеленоград", + "Иваново", + "Ижевск", + "Иркутск", + "Йошкар-Ола", + "Казань", + "Калининград", + "Калуга", + "Каменск-Уральский", + "Кемерово", + "Киров", + "Кисловодск", + "Клин", + "Кострома", + "Краснодар", + "Красноярск", + "Курган", + "Курск", + "Липецк", + "Магадан", + "Магнитогорск", + "Майкоп", + "Махачкала", + "Минеральные Воды", + "Москва", + "Мурманск", + "Муром", + "Мытищи", + "Набережные Челны", + "Нальчик", + "Находка", + "Невинномысск", + "Нижний Новгород", + "Нижний Тагил", + "Новороссийск", + "Новосибирск", + "Новочеркасск", + "Новокузнецк", + "Норильск", + "Обнинск", + "Омск", + "Орёл", + "Оренбург", + "Орск", + "Пенза", + "Пермь", + "Петрозаводск", + "Петропавловск-Камчатский", + "Псков", + "Пятигорск", + "Ростов-на-Дону", + "Рязань", + "Самара", + "Санкт-Петербург", + "Саратов", + "Саров", + "Севастополь", + "Северодвинск", + "Смоленск", + "Сочи", + "Ставрополь", + "Старый Оскол", + "Стерлитамак", + "Сургут", + "Сызрань", + "Сыктывкар", + "Таганрог", + "Тамбов", + "Тверь", + "Тольятти", + "Томск", + "Туапсе", + "Тула", + "Тюмень", + "Улан-Удэ", + "Ульяновск", + "Уфа", + "Хабаровск", + "Ханты-Мансийск", + "Химки", + "Чебоксары", + "Челябинск", + "Череповец", + "Чита", + "Элиста", + "Южно-Сахалинск", + "Якутск", + "Ялта", + "Ярославль", + # International cities in Russian spelling (optional but handy). + "Амстердам", + "Афины", + "Баку", + "Белград", + "Берлин", + "Варшава", + "Вена", + "Вильнюс", + "Дели", + "Дублин", + "Женева", + "Иерусалим", + "Каир", + "Киев", + "Лиссабон", + "Лондон", + "Мадрид", + "Минск", + "Монако", + "Нью-Йорк", + "Осака", + "Осло", + "Париж", + "Прага", + "Рига", + "Рим", + "Стамбул", + "Таллин", + "Тбилиси", + "Токио", + "Хельсинки", + "Цюрих", +] + + +def _normalize(text: str) -> str: + text = text.lower().replace("ё", "е") + text = re.sub(r"[^a-zа-я\s-]+", " ", text, flags=re.IGNORECASE) + text = text.replace("-", " ") + text = re.sub(r"\s+", " ", text).strip() + return text + + +def _first_letter(text: str) -> Optional[str]: + for ch in text: + if ch.isalpha(): + return ch + return None + + +def _last_letter(text: str) -> Optional[str]: + for ch in reversed(text): + if not ch.isalpha(): + continue + if ch in _EXCLUDED_LAST_LETTERS: + continue + return ch + return None + + +def _tokenize(text: str) -> list[str]: + return [m.group(0).lower().replace("ё", "е") for m in _CITY_TOKEN_RE.finditer(text)] + + +_CITY_MAP = {} +for city in _CITIES: + norm = _normalize(city) + if norm not in _CITY_MAP: + _CITY_MAP[norm] = city + +_CITY_NORMS = sorted(_CITY_MAP.keys(), key=lambda s: (len(s), s)) + +_CITY_BY_FIRST = {} +for norm, display in _CITY_MAP.items(): + letter = _first_letter(norm) + if not letter: + continue + _CITY_BY_FIRST.setdefault(letter, []).append((norm, display)) + +for letter in _CITY_BY_FIRST: + _CITY_BY_FIRST[letter].sort(key=lambda pair: pair[0]) + + +_START_PATTERNS = [ + r"\b(игра|сыграем|сыграть|давай|хочу|можем)\b.*\bгорода\b", + r"\bв\s+города\b", + r"^города$", +] +_STOP_PATTERNS = [ + r"\b(стоп|хватит|выход|конец|закончим|останови)\b.*\b(игру|игра|города)?\b", + r"\bсдаюсь\b", +] + + +def _is_start_command(text: str) -> bool: + return any(re.search(p, text, flags=re.IGNORECASE) for p in _START_PATTERNS) + + +def _is_stop_command(text: str) -> bool: + return any(re.search(p, text, flags=re.IGNORECASE) for p in _STOP_PATTERNS) + + +def _extract_city(text: str) -> Optional[str]: + tokens = _tokenize(text) + if not tokens: + return None + + # Try to find the longest matching city name among tokens. + max_len = min(4, len(tokens)) + for span in range(max_len, 0, -1): + for i in range(len(tokens) - span + 1): + candidate = " ".join(tokens[i : i + span]) + if candidate in _CITY_MAP: + return candidate + + # Fallback: treat the last token as a city if it looks plausible. + candidate = tokens[-1] + if candidate in _NON_CITY_TOKENS: + return None + if len(candidate) >= 3: + return candidate + return None + + +@dataclass +class CitiesGame: + active: bool = False + required_letter: Optional[str] = None + used: set[str] = field(default_factory=set) + + def start(self) -> str: + self.active = True + self.required_letter = None + self.used.clear() + return "Давай сыграем в города. Назови город." + + def stop(self) -> str: + self.active = False + self.required_letter = None + self.used.clear() + return "Хорошо, заканчиваем игру в города." + + def handle(self, text: str) -> Optional[str]: + normalized = _normalize(text) + + if not self.active: + if _is_start_command(normalized): + return self.start() + return None + + if _is_stop_command(normalized): + return self.stop() + + city_norm = _extract_city(normalized) + if not city_norm: + return "Назови город." + + if self.required_letter and not city_norm.startswith(self.required_letter): + return f"Нужно на букву {self.required_letter.upper()}." + + if city_norm in self.used: + return "Этот город уже был. Назови другой." + + self.used.add(city_norm) + + next_letter = _last_letter(city_norm) + if not next_letter: + return "Не могу определить следующую букву. Назови другой город." + + candidates = _CITY_BY_FIRST.get(next_letter, []) + for candidate_norm, candidate_display in candidates: + if candidate_norm not in self.used: + self.used.add(candidate_norm) + self.required_letter = _last_letter(candidate_norm) + return candidate_display + + self.active = False + self.required_letter = None + return f"Не знаю город на букву {next_letter.upper()}. Ты победил." + + +_game = None + + +def get_cities_game() -> CitiesGame: + global _game + if _game is None: + _game = CitiesGame() + return _game diff --git a/app/main.py b/app/main.py index c31126e..8208d58 100644 --- a/app/main.py +++ b/app/main.py @@ -56,6 +56,7 @@ from .features.alarm import get_alarm_clock from .features.timer import get_timer_manager from .features.weather import get_weather_report from .features.music import get_music_controller +from .features.cities_game import get_cities_game def signal_handler(sig, frame): """ @@ -176,6 +177,7 @@ def main(): init_tts() # Загрузка нейросети для синтеза речи (Silero) alarm_clock = get_alarm_clock() # Загрузка будильников timer_manager = get_timer_manager() # Загрузка таймеров + cities_game = get_cities_game() # Игра "Города" print() # История чата (храним последние 10 обменов репликами для контекста) @@ -479,6 +481,15 @@ def main(): print("⏹️ Перевод прерван - слушаю следующий вопрос") continue + # Игра "Города" + cities_response = cities_game.handle(user_text) + if cities_response: + clean_cities_response = clean_response(cities_response, language="ru") + speak(clean_cities_response) + last_response = clean_cities_response + skip_wakeword = True + continue + # --- Шаг 3: Запрос к AI (Streaming) --- # Добавляем сообщение пользователя в историю chat_history.append({"role": "user", "content": user_text})