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

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