Улучшенный будильник, таймер, перевод

This commit is contained in:
2026-02-01 19:59:18 +03:00
parent 49dbaad122
commit d0b12009b3
15 changed files with 1013 additions and 105 deletions

View File

@@ -5,19 +5,307 @@
import subprocess
import re
import json
from datetime import datetime, timedelta
from pathlib import Path
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
# Morphological analysis for better recognition of number words.
try:
import pymorphy3
_MORPH = pymorphy3.MorphAnalyzer()
except Exception:
_MORPH = None
# Звуковой файл сигнала (используем тот же, что и для будильника)
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
TIMER_FILE = BASE_DIR / "data" / "timers.json"
# --- Number words parsing helpers (ru) ---
_NUMBER_UNITS = {
"ноль": 0,
"один": 1,
"два": 2,
"три": 3,
"четыре": 4,
"пять": 5,
"шесть": 6,
"семь": 7,
"восемь": 8,
"девять": 9,
}
_NUMBER_TEENS = {
"десять": 10,
"одиннадцать": 11,
"двенадцать": 12,
"тринадцать": 13,
"четырнадцать": 14,
"пятнадцать": 15,
"шестнадцать": 16,
"семнадцать": 17,
"восемнадцать": 18,
"девятнадцать": 19,
}
_NUMBER_TENS = {
"двадцать": 20,
"тридцать": 30,
"сорок": 40,
"пятьдесят": 50,
"шестьдесят": 60,
"семьдесят": 70,
"восемьдесят": 80,
"девяносто": 90,
}
_NUMBER_HUNDREDS = {
"сто": 100,
"двести": 200,
"триста": 300,
"четыреста": 400,
"пятьсот": 500,
"шестьсот": 600,
"семьсот": 700,
"восемьсот": 800,
"девятьсот": 900,
}
_NUMBER_SPECIAL = {
"пол": 0.5,
"полтора": 1.5,
"полторы": 1.5,
}
_NUMBER_LEMMAS = (
set(_NUMBER_UNITS)
| set(_NUMBER_TEENS)
| set(_NUMBER_TENS)
| set(_NUMBER_HUNDREDS)
| set(_NUMBER_SPECIAL)
)
_IGNORED_LEMMAS = {
"на",
"в",
"во",
"за",
"через",
"по",
"к",
"ко",
"с",
"со",
"и",
}
_UNIT_LEMMAS = {
"час": "hours",
"минута": "minutes",
"секунда": "seconds",
"мин": "minutes",
"сек": "seconds",
}
_UNIT_FORMS = {
"hours": ("час", "часа", "часов"),
"minutes": ("минуту", "минуты", "минут"),
"seconds": ("секунду", "секунды", "секунд"),
}
# Optional ordinal formatting for list numbering.
try:
from num2words import num2words
except Exception:
num2words = None
def _lemmatize(token: str) -> str:
if _MORPH is None:
return token
return _MORPH.parse(token)[0].normal_form
def _tokenize_with_lemmas(text: str):
tokens = []
for match in re.finditer(r"[a-zA-Zа-яА-ЯёЁ]+|\d+", text.lower()):
raw = match.group(0)
lemma = _lemmatize(raw) if not raw.isdigit() else raw
tokens.append({"raw": raw, "lemma": lemma})
return tokens
def _parse_number_lemmas(lemmas):
if not lemmas:
return None
if len(lemmas) == 1 and lemmas[0] in _NUMBER_SPECIAL:
return _NUMBER_SPECIAL[lemmas[0]]
total = 0
idx = 0
if lemmas[idx] in _NUMBER_HUNDREDS:
total += _NUMBER_HUNDREDS[lemmas[idx]]
idx += 1
if idx < len(lemmas) and lemmas[idx] in _NUMBER_TEENS:
total += _NUMBER_TEENS[lemmas[idx]]
idx += 1
else:
if idx < len(lemmas) and lemmas[idx] in _NUMBER_TENS:
total += _NUMBER_TENS[lemmas[idx]]
idx += 1
if idx < len(lemmas) and lemmas[idx] in _NUMBER_UNITS:
total += _NUMBER_UNITS[lemmas[idx]]
idx += 1
if total == 0 and lemmas[0] in _NUMBER_UNITS:
return _NUMBER_UNITS[lemmas[0]]
return total if total > 0 else None
def _normalize_timer_text(text: str) -> str:
# Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing.
return re.sub(
r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)",
"пол ",
text,
)
def _find_word_number_before_unit(tokens, unit_index):
collected = []
idx = unit_index - 1
while idx >= 0 and len(collected) < 4:
lemma = tokens[idx]["lemma"]
if lemma in _IGNORED_LEMMAS:
idx -= 1
continue
if lemma in _NUMBER_LEMMAS:
collected.insert(0, lemma)
idx -= 1
continue
break
return _parse_number_lemmas(collected)
def _extract_word_time_values(text: str):
tokens = _tokenize_with_lemmas(text)
values = {"hours": None, "minutes": None, "seconds": None}
for idx, token in enumerate(tokens):
lemma = token["lemma"]
key = _UNIT_LEMMAS.get(lemma)
if not key:
continue
value = _find_word_number_before_unit(tokens, idx)
if value is not None:
values[key] = value
return values
def _format_unit(value: int, unit_key: str) -> str:
if unit_key not in _UNIT_FORMS:
return f"{value}"
one, few, many = _UNIT_FORMS[unit_key]
n = abs(int(value))
if n % 100 in (11, 12, 13, 14):
word = many
elif n % 10 == 1:
word = one
elif n % 10 in (2, 3, 4):
word = few
else:
word = many
return f"{value} {word}"
def _format_duration(total_seconds: float) -> str:
total_seconds = int(round(total_seconds))
if total_seconds < 0:
total_seconds = 0
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
parts = []
if hours:
parts.append(_format_unit(hours, "hours"))
if minutes:
parts.append(_format_unit(minutes, "minutes"))
if seconds or not parts:
parts.append(_format_unit(seconds, "seconds"))
return " ".join(parts)
def _format_ordinal_index(index: int) -> str:
if num2words is None:
return f"{index}"
try:
return num2words(index, lang="ru", to="ordinal", case="nominative", gender="m")
except Exception:
return f"{index}"
class TimerManager:
def __init__(self):
# Список активных таймеров: {"end_time": datetime, "label": str}
self.timers = []
self.load_timers()
def load_timers(self):
"""Загрузка списка таймеров из JSON файла."""
if TIMER_FILE.exists():
try:
with open(TIMER_FILE, "r", encoding="utf-8") as f:
raw = json.load(f)
except Exception as e:
print(f"❌ Ошибка загрузки таймеров: {e}")
return
timers = []
for item in raw:
try:
end_time = datetime.fromisoformat(item["end_time"])
except Exception:
continue
label = item.get("label", "")
timers.append({"end_time": end_time, "label": label})
self.timers = sorted(timers, key=lambda x: x["end_time"])
def save_timers(self):
"""Сохранение списка таймеров в JSON файл."""
payload = [
{"end_time": t["end_time"].isoformat(), "label": t.get("label", "")}
for t in self.timers
]
try:
with open(TIMER_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=4)
except Exception as e:
print(f"❌ Ошибка сохранения таймеров: {e}")
def describe_timers(self) -> str:
"""Возвращает текстовое описание активных таймеров."""
if not self.timers:
return "Активных таймеров нет."
now = datetime.now()
items = []
for idx, timer in enumerate(self.timers, start=1):
remaining = (timer["end_time"] - now).total_seconds()
label = timer.get("label", "").strip()
ordinal = _format_ordinal_index(idx)
if remaining <= 0:
status = "сработает сейчас"
else:
status = f"осталось {_format_duration(remaining)}"
if label:
items.append(f"{ordinal}) {label}{status}")
else:
items.append(f"{ordinal}) {status}")
return "Активные таймеры: " + "; ".join(items) + "."
def add_timer(self, seconds: int, label: str):
"""Добавление нового таймера."""
@@ -25,12 +313,14 @@ class TimerManager:
self.timers.append({"end_time": end_time, "label": label})
# Сортируем, чтобы ближайший был первым
self.timers.sort(key=lambda x: x["end_time"])
self.save_timers()
print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})")
def cancel_all_timers(self):
"""Отмена всех таймеров."""
count = len(self.timers)
self.timers = []
self.save_timers()
print(f"🔕 Все таймеры ({count}) отменены.")
def check_timers(self):
@@ -52,6 +342,7 @@ class TimerManager:
# Удаляем его из списка
label = first_timer["label"]
self.timers.pop(0)
self.save_timers()
print(f"⌛ ТАЙМЕР ИСТЕК: {label}")
self.trigger_timer(label)
@@ -78,22 +369,11 @@ class TimerManager:
return
try:
stop_words = [
"стоп",
"хватит",
"тихо",
"замолчи",
"отмена",
"александр стоп",
"спасибо",
]
# Цикл ожидания стоп-команды
while True:
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
if text:
text_lower = text.lower()
if any(word in text_lower for word in stop_words):
if is_stop_command(text, mode="lenient"):
print(f"🛑 Таймер остановлен по команде: '{text}'")
break
@@ -113,12 +393,17 @@ class TimerManager:
Парсинг команды установки таймера.
Примеры: "таймер на 5 минут", "засеки 10 секунд".
"""
text = text.lower()
text = _normalize_timer_text(text.lower())
# Ключевые слова для таймера
if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]):
return None
if "таймер" in text and re.search(
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
):
return self.describe_timers()
if "отмени" in text or "удали" in text:
self.cancel_all_timers()
return "Хорошо, все таймеры отменены."
@@ -128,37 +413,69 @@ class TimerManager:
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
total_seconds = 0
found_time = False
parts = []
hours = None
minutes = None
seconds = None
has_fractional = False
# Часы
match_hours = re.search(r"(\d+)\s*(?:час|часа|часов)", text)
if match_hours:
h = int(match_hours.group(1))
total_seconds += h * 3600
parts.append(f"{h} ч")
found_time = True
hours = int(match_hours.group(1))
# Минуты
match_minutes = re.search(r"(\d+)\s*(?:мин|минуту|минуты|минут)", text)
if match_minutes:
m = int(match_minutes.group(1))
total_seconds += m * 60
parts.append(f"{m} мин")
found_time = True
minutes = int(match_minutes.group(1))
# Секунды
match_seconds = re.search(r"(\d+)\s*(?:сек|секунду|секунды|секунд)", text)
if match_seconds:
s = int(match_seconds.group(1))
total_seconds += s
parts.append(f"{s} сек")
found_time = True
seconds = int(match_seconds.group(1))
# Дополняем числительные словами (например, "одну минуту")
word_values = _extract_word_time_values(text)
if hours is None and word_values["hours"] is not None:
hours = word_values["hours"]
if minutes is None and word_values["minutes"] is not None:
minutes = word_values["minutes"]
if seconds is None and word_values["seconds"] is not None:
seconds = word_values["seconds"]
if hours is not None and isinstance(hours, float) and hours % 1 != 0:
has_fractional = True
if minutes is not None and isinstance(minutes, float) and minutes % 1 != 0:
has_fractional = True
if seconds is not None and isinstance(seconds, float) and seconds % 1 != 0:
has_fractional = True
found_time = any(value is not None for value in [hours, minutes, seconds])
if found_time:
total_seconds = (
(hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
)
if has_fractional:
total_seconds = int(round(total_seconds))
h = total_seconds // 3600
m = (total_seconds % 3600) // 60
s = total_seconds % 60
else:
h = int(hours) if hours is not None else 0
m = int(minutes) if minutes is not None else 0
s = int(seconds) if seconds is not None else 0
if h:
parts.append(_format_unit(h, "hours"))
if m:
parts.append(_format_unit(m, "minutes"))
if s or not parts:
parts.append(_format_unit(s, "seconds"))
if found_time and total_seconds > 0:
label = " ".join(parts)
self.add_timer(total_seconds, label)
return f"Засек {label}."
return f"Поставил таймер на {label}."
# Если сказали "таймер", но не нашли время
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
@@ -172,4 +489,4 @@ def get_timer_manager():
global _timer_manager
if _timer_manager is None:
_timer_manager = TimerManager()
return _timer_manager
return _timer_manager