Add cities game to avoid rambling responses

This commit is contained in:
2026-02-02 23:40:36 +03:00
parent a61f526ce4
commit 38b95e0de6
2 changed files with 336 additions and 0 deletions

325
app/features/cities_game.py Normal file
View 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"\\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