Add cities game to avoid rambling responses
This commit is contained in:
325
app/features/cities_game.py
Normal file
325
app/features/cities_game.py
Normal file
@@ -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
|
||||||
11
app/main.py
11
app/main.py
@@ -56,6 +56,7 @@ from .features.alarm import get_alarm_clock
|
|||||||
from .features.timer import get_timer_manager
|
from .features.timer import get_timer_manager
|
||||||
from .features.weather import get_weather_report
|
from .features.weather import get_weather_report
|
||||||
from .features.music import get_music_controller
|
from .features.music import get_music_controller
|
||||||
|
from .features.cities_game import get_cities_game
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
"""
|
"""
|
||||||
@@ -176,6 +177,7 @@ def main():
|
|||||||
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
||||||
alarm_clock = get_alarm_clock() # Загрузка будильников
|
alarm_clock = get_alarm_clock() # Загрузка будильников
|
||||||
timer_manager = get_timer_manager() # Загрузка таймеров
|
timer_manager = get_timer_manager() # Загрузка таймеров
|
||||||
|
cities_game = get_cities_game() # Игра "Города"
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# История чата (храним последние 10 обменов репликами для контекста)
|
# История чата (храним последние 10 обменов репликами для контекста)
|
||||||
@@ -479,6 +481,15 @@ def main():
|
|||||||
print("⏹️ Перевод прерван - слушаю следующий вопрос")
|
print("⏹️ Перевод прерван - слушаю следующий вопрос")
|
||||||
continue
|
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) ---
|
# --- Шаг 3: Запрос к AI (Streaming) ---
|
||||||
# Добавляем сообщение пользователя в историю
|
# Добавляем сообщение пользователя в историю
|
||||||
chat_history.append({"role": "user", "content": user_text})
|
chat_history.append({"role": "user", "content": user_text})
|
||||||
|
|||||||
Reference in New Issue
Block a user