Update assistant features and docs

This commit is contained in:
2026-02-12 14:12:37 +03:00
parent bb3133a1c0
commit ca8ebd6657
19 changed files with 814 additions and 180 deletions

View File

@@ -10,11 +10,13 @@ from datetime import datetime
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
from ..core.roman import replace_roman_numerals
# Файл базы данных будильников
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
# Звуковой файл сигнала
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
ASK_ALARM_TIME_PROMPT = "На какое время мне поставить будильник?"
class AlarmClock:
@@ -229,7 +231,7 @@ class AlarmClock:
try:
# Цикл ожидания стоп-команды
while True:
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
text = listen(timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True)
if text:
if is_stop_command(text, mode="lenient"):
print(f"🛑 Будильник остановлен по команде: '{text}'")
@@ -251,7 +253,7 @@ class AlarmClock:
Парсинг команды установки будильника из текста.
Примеры: "разбуди в 7:30", "будильник на 8 утра".
"""
text = text.lower()
text = replace_roman_numerals(text.lower())
if "будильник" not in text and "разбуди" not in text:
return None
@@ -299,6 +301,12 @@ class AlarmClock:
suffix = f" {days_phrase}" if days_phrase else ""
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in {
"будильник",
"поставь будильник",
}:
return ASK_ALARM_TIME_PROMPT
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."

View File

@@ -9,6 +9,7 @@ Spotify Music Controller
- "следующий трек" / "next" - следующий трек
- "предыдущий трек" / "previous" - предыдущий трек
- "что играет" / "какая песня" - информация о текущем треке
- "угадай песню" / "распознай музыку" - распознавание текущего трека
"""
import os
@@ -287,6 +288,16 @@ class SpotifyMusicController:
if re.search(pattern, text_lower) and ("трек" in text_lower or "песн" in text_lower or "previous" in text_lower or "back" in text_lower):
return self.previous_track()
# Явные команды распознавания музыки (типа "угадай песню")
recognize_patterns = [
r"((александр|александра|алесандр|alexander)\s+)?(угадай|распознай|определи)\s+(мелод|музык|песн|трек)",
r"((александр|александра|алесандр|alexander)\s+)?(что за|какая это)\s+(музык|песн|трек)",
r"(identify|recognize)\s+(song|music|track)",
]
for pattern in recognize_patterns:
if re.search(pattern, text_lower):
return self.get_current_track()
# Что играет
current_patterns = [
r"(что (сейчас )?играет|как(ая|ой) (песня|трек)|что за (песня|трек|музыка))",

267
app/features/stopwatch.py Normal file
View File

@@ -0,0 +1,267 @@
"""Stopwatch module."""
import json
import re
from datetime import datetime
from ..core.config import BASE_DIR
STOPWATCH_FILE = BASE_DIR / "data" / "stopwatches.json"
# Optional ordinal formatting for list numbering.
try:
from num2words import num2words
except Exception:
num2words = None
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}"
def _format_duration(seconds: float) -> str:
total = int(round(max(0, seconds)))
hours = total // 3600
minutes = (total % 3600) // 60
sec = total % 60
parts = []
if hours:
parts.append(f"{hours} ч")
if minutes:
parts.append(f"{minutes} мин")
parts.append(f"{sec} сек")
return " ".join(parts)
class StopwatchManager:
def __init__(self):
self.stopwatches = []
self.load_stopwatches()
def load_stopwatches(self):
if not STOPWATCH_FILE.exists():
return
try:
with open(STOPWATCH_FILE, "r", encoding="utf-8") as f:
raw = json.load(f)
except Exception as e:
print(f"❌ Ошибка загрузки секундомеров: {e}")
return
items = []
for item in raw:
try:
stopwatch_id = int(item["id"])
except Exception:
continue
items.append(
{
"id": stopwatch_id,
"name": str(item.get("name", "")).strip(),
"elapsed": float(item.get("elapsed", 0)),
"running": bool(item.get("running", False)),
"started_at": item.get("started_at"),
}
)
self.stopwatches = sorted(items, key=lambda x: x["id"])
def save_stopwatches(self):
payload = [
{
"id": sw["id"],
"name": sw.get("name", ""),
"elapsed": sw.get("elapsed", 0),
"running": sw.get("running", False),
"started_at": sw.get("started_at"),
}
for sw in self.stopwatches
]
try:
with open(STOPWATCH_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=4)
except Exception as e:
print(f"❌ Ошибка сохранения секундомеров: {e}")
def _next_id(self) -> int:
if not self.stopwatches:
return 1
return max(sw["id"] for sw in self.stopwatches) + 1
def _now_iso(self) -> str:
return datetime.now().isoformat()
def _elapsed_now(self, stopwatch: dict) -> float:
elapsed = float(stopwatch.get("elapsed", 0))
if not stopwatch.get("running"):
return elapsed
started_at = stopwatch.get("started_at")
if not started_at:
return elapsed
try:
started_dt = datetime.fromisoformat(started_at)
except Exception:
return elapsed
delta = (datetime.now() - started_dt).total_seconds()
return elapsed + max(0, delta)
def _running(self):
return [sw for sw in self.stopwatches if sw.get("running")]
def _paused(self):
return [sw for sw in self.stopwatches if not sw.get("running")]
def has_running_stopwatches(self) -> bool:
return bool(self._running())
def describe_active_stopwatches(self) -> str:
running = self._running()
if not running:
return "Активных секундомеров нет."
running.sort(key=lambda sw: sw["id"])
items = []
for idx, sw in enumerate(running, start=1):
ordinal = _format_ordinal_index(idx)
duration = _format_duration(self._elapsed_now(sw))
name = sw.get("name", "")
if name:
items.append(f"{ordinal}) {name}{duration}")
else:
items.append(f"{ordinal}) {duration}")
return "Активные секундомеры: " + "; ".join(items) + "."
def start_stopwatch(self, name: str = "") -> str:
stopwatch = {
"id": self._next_id(),
"name": name.strip(),
"elapsed": 0.0,
"running": True,
"started_at": self._now_iso(),
}
self.stopwatches.append(stopwatch)
self.save_stopwatches()
if stopwatch["name"]:
return f"Запустил секундомер «{stopwatch['name']}»."
return "Запустил секундомер."
def pause_stopwatches(self) -> str:
running = self._running()
if not running:
return "Сейчас нет активных секундомеров."
elapsed_items = []
for sw in running:
elapsed_now = self._elapsed_now(sw)
elapsed_items.append(
{
"id": sw["id"],
"name": sw.get("name", ""),
"elapsed": elapsed_now,
}
)
sw["elapsed"] = elapsed_now
sw["running"] = False
sw["started_at"] = None
self.save_stopwatches()
count = len(running)
if count == 1:
elapsed_text = _format_duration(elapsed_items[0]["elapsed"])
return f"Остановил секундомер. Он работал {elapsed_text}."
details = []
for idx, item in enumerate(sorted(elapsed_items, key=lambda x: x["id"]), start=1):
ordinal = _format_ordinal_index(idx)
elapsed_text = _format_duration(item["elapsed"])
name = item.get("name", "")
if name:
details.append(f"{ordinal} «{name}» — {elapsed_text}")
else:
details.append(f"{ordinal}{elapsed_text}")
return f"Остановил секундомеры: {count} шт. Время: " + "; ".join(details) + "."
def resume_stopwatches(self) -> str:
paused = self._paused()
if not paused:
return "Пауза не активна: секундомеры уже запущены или отсутствуют."
for sw in paused:
sw["running"] = True
sw["started_at"] = self._now_iso()
self.save_stopwatches()
count = len(paused)
if count == 1:
return "Продолжил секундомер."
return f"Продолжил секундомеры: {count} шт."
def reset_stopwatches(self) -> str:
if not self.stopwatches:
return "Секундомеров для сброса нет."
count = len(self.stopwatches)
self.stopwatches = []
self.save_stopwatches()
if count == 1:
return "Секундомер сброшен."
return f"Сбросил секундомеры: {count} шт."
def parse_command(self, text: str) -> str | None:
text = text.lower().strip()
has_stopwatch_word = any(
word in text
for word in [
"секундомер",
"секундомеры",
"секундомером",
"секундомера",
"секундомеру",
]
)
if not has_stopwatch_word:
return None
if re.search(r"(какие|какой|список|активн|покажи|сколько|есть ли)", text):
return self.describe_active_stopwatches()
if any(word in text for word in ["сброс", "удали", "отмени", "очист"]):
return self.reset_stopwatches()
if any(word in text for word in ["продолж", "возобнов"]):
return self.resume_stopwatches()
if any(word in text for word in ["стоп", "останов", "пауза"]):
return self.pause_stopwatches()
if "постав" in text or "установ" in text:
return self.start_stopwatch()
if any(word in text for word in ["запусти", "включи", "старт", "начни"]):
return self.start_stopwatch()
# Если пользователь просто сказал "секундомер", трактуем как запуск.
if text in {"секундомер", "запусти секундомер", "включи секундомер"}:
return self.start_stopwatch()
return "Я понял команду про секундомер, но не распознал действие. Скажите: запусти, стоп, продолжи, сбрось или покажи активные секундомеры."
_stopwatch_manager = None
def get_stopwatch_manager():
global _stopwatch_manager
if _stopwatch_manager is None:
_stopwatch_manager = StopwatchManager()
return _stopwatch_manager

View File

@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
from ..core.roman import replace_roman_numerals
# Morphological analysis for better recognition of number words.
try:
@@ -22,6 +23,7 @@ except Exception:
# Звуковой файл сигнала (используем тот же, что и для будильника)
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
TIMER_FILE = BASE_DIR / "data" / "timers.json"
ASK_TIMER_TIME_PROMPT = "На какое время мне поставить таймер?"
# --- Number words parsing helpers (ru) ---
_NUMBER_UNITS = {
@@ -162,11 +164,13 @@ def _parse_number_lemmas(lemmas):
def _normalize_timer_text(text: str) -> str:
# Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing.
return re.sub(
text = re.sub(
r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)",
"пол ",
text,
)
# Support commands like "таймер на X минут".
return replace_roman_numerals(text)
def _find_word_number_before_unit(tokens, unit_index):
@@ -371,7 +375,7 @@ class TimerManager:
try:
# Цикл ожидания стоп-команды
while True:
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
text = listen(timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True)
if text:
if is_stop_command(text, mode="lenient"):
print(f"🛑 Таймер остановлен по команде: '{text}'")
@@ -477,7 +481,14 @@ class TimerManager:
self.add_timer(total_seconds, label)
return f"Поставил таймер на {label}."
# Если сказали "таймер", но не нашли время
# Если попросили поставить таймер, но не назвали время — задаем уточняющий вопрос.
if re.search(r"(постав|установ|запусти|включи|засеки)", text) or text.strip() in {
"таймер",
"поставь таймер",
}:
return ASK_TIMER_TIME_PROMPT
# Если сказали "таймер", но не нашли время.
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."