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
|
||||
Reference in New Issue
Block a user