279 lines
12 KiB
Python
279 lines
12 KiB
Python
|
|
"""Python parity for Wave 6-8 voice packs that were Rust-only at first.
|
|||
|
|
|
|||
|
|
Covers:
|
|||
|
|
- memory_admin: count / list / wipe
|
|||
|
|
- cooking: dish-name → preset-duration timer via scheduler
|
|||
|
|
- leftoff: cross-state recap (no LLM)
|
|||
|
|
- trivia: random MCU one-liner
|
|||
|
|
- routines: good_night / good_morning / coffee_break
|
|||
|
|
|
|||
|
|
Style: each `do_*` returns nothing; speech goes through the `set_speak`-
|
|||
|
|
injected callback, mirroring how the other handlers (wave1_handlers,
|
|||
|
|
personality_handlers, ...) work. Keeps the handler module testable in
|
|||
|
|
isolation since the speak fn is a thin dependency.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import random
|
|||
|
|
import time
|
|||
|
|
from typing import Callable, Optional
|
|||
|
|
|
|||
|
|
import memory_store
|
|||
|
|
import scheduler_store
|
|||
|
|
import profiles_store
|
|||
|
|
|
|||
|
|
|
|||
|
|
_speak: Optional[Callable[[str], None]] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_speak(fn: Callable[[str], None]) -> None:
|
|||
|
|
"""Wired by extensions.py at startup; lets main.py route TTS through whichever
|
|||
|
|
backend the user has active (Silero / Piper / SAPI)."""
|
|||
|
|
global _speak
|
|||
|
|
_speak = fn
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _say(text: str) -> None:
|
|||
|
|
if _speak is not None:
|
|||
|
|
_speak(text)
|
|||
|
|
else:
|
|||
|
|
# Tests don't always wire a speak fn — fall back to print so the test
|
|||
|
|
# can still assert on the captured content via capsys/caplog.
|
|||
|
|
print(f"[wave68] {text}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Memory admin ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _ru_plural_facts(n: int) -> str:
|
|||
|
|
"""Russian declension: 1 факт, 2-4 факта, 5+ фактов, 11-14 exception."""
|
|||
|
|
m100 = n % 100
|
|||
|
|
if 11 <= m100 <= 14:
|
|||
|
|
return "фактов"
|
|||
|
|
m10 = n % 10
|
|||
|
|
if m10 == 1:
|
|||
|
|
return "факт"
|
|||
|
|
if 2 <= m10 <= 4:
|
|||
|
|
return "факта"
|
|||
|
|
return "фактов"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_memory_count(action=None, voice=None) -> None:
|
|||
|
|
n = len(memory_store.all_facts())
|
|||
|
|
_say(f"В памяти {n} {_ru_plural_facts(n)}, сэр.")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_memory_list(action=None, voice=None) -> None:
|
|||
|
|
facts = memory_store.all_facts()
|
|||
|
|
if not facts:
|
|||
|
|
_say("Память пуста, сэр.")
|
|||
|
|
return
|
|||
|
|
# Sort by recency
|
|||
|
|
facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True)
|
|||
|
|
preview = facts[:5]
|
|||
|
|
rest = max(0, len(facts) - len(preview))
|
|||
|
|
parts = [f"{f['key']} — {(f.get('value') or '')[:60]}" for f in preview]
|
|||
|
|
msg = ", ".join(parts)
|
|||
|
|
if rest > 0:
|
|||
|
|
msg += f". И ещё {rest}."
|
|||
|
|
_say(msg)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_memory_wipe(action=None, voice=None) -> None:
|
|||
|
|
n = memory_store.clear_all()
|
|||
|
|
if n == 0:
|
|||
|
|
_say("Память уже пуста, сэр.")
|
|||
|
|
else:
|
|||
|
|
_say(f"Стёр {n} записей. Память чиста.")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Cooking timer ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
# (lowercase substring, minutes, ru-label, en-label)
|
|||
|
|
_RECIPES = [
|
|||
|
|
("чайник", 4, "чайник", "kettle"),
|
|||
|
|
("французск", 4, "френч-пресс", "french press"),
|
|||
|
|
("чай", 5, "чай", "tea"),
|
|||
|
|
("омлет", 5, "омлет", "omelette"),
|
|||
|
|
("яичниц", 5, "яичница", "fried eggs"),
|
|||
|
|
("кофе", 5, "кофе", "coffee"),
|
|||
|
|
("яйц", 8, "яйца", "eggs"),
|
|||
|
|
("макарон", 10, "макароны", "pasta"),
|
|||
|
|
("паст", 10, "паста", "pasta"),
|
|||
|
|
("пицц", 15, "пицца", "pizza"),
|
|||
|
|
("рис", 18, "рис", "rice"),
|
|||
|
|
("греч", 20, "гречка", "buckwheat"),
|
|||
|
|
("каш", 20, "каша", "porridge"),
|
|||
|
|
("картош", 22, "картошка", "potatoes"),
|
|||
|
|
("куриц", 35, "курица", "chicken"),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def match_recipe(phrase: str):
|
|||
|
|
"""Return (minutes, ru_label) for the first matching recipe substring,
|
|||
|
|
or None if nothing in the phrase matches. Exposed for tests."""
|
|||
|
|
p = (phrase or "").lower()
|
|||
|
|
for sub, mins, ru, _en in _RECIPES:
|
|||
|
|
if sub in p:
|
|||
|
|
return mins, ru
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_cooking(action=None, voice=None) -> None:
|
|||
|
|
"""Detect dish in the voice phrase, schedule a 'ready' reminder for the
|
|||
|
|
right number of minutes through the persistent scheduler.
|
|||
|
|
|
|||
|
|
Signature matches the rest of the `do_*` handlers: (action_dict, voice).
|
|||
|
|
The recipe match looks at `voice` (the raw phrase the user said)."""
|
|||
|
|
phrase = voice or ""
|
|||
|
|
match = match_recipe(phrase)
|
|||
|
|
if match is None:
|
|||
|
|
_say("Не понял, что ты готовишь, сэр.")
|
|||
|
|
return
|
|||
|
|
minutes, label = match
|
|||
|
|
spec = f"in {minutes} minutes"
|
|||
|
|
try:
|
|||
|
|
sched = scheduler_store.parse_schedule(spec)
|
|||
|
|
except ValueError as e:
|
|||
|
|
_say(f"Не получилось распарсить расписание: {e}")
|
|||
|
|
return
|
|||
|
|
task = {
|
|||
|
|
"name": f"Кухня: {label}",
|
|||
|
|
"schedule": sched,
|
|||
|
|
"action": {"type": "speak", "text": f"Сэр, {label} готов."},
|
|||
|
|
}
|
|||
|
|
scheduler_store.add(task)
|
|||
|
|
_say(f"Хорошо, напомню через {minutes} минут когда {label} будет готов.")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Leftoff recap ────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_leftoff(action=None, voice=None, last_assistant_reply: Optional[str] = None) -> None:
|
|||
|
|
"""Stitch a recap from independent sources. Pure-offline.
|
|||
|
|
|
|||
|
|
`last_assistant_reply` is callable from tests to pin the third source;
|
|||
|
|
the production dispatcher passes None (main.py keeps its own LLM history)."""
|
|||
|
|
parts = []
|
|||
|
|
|
|||
|
|
facts = memory_store.all_facts()
|
|||
|
|
if facts:
|
|||
|
|
facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True)[:3]
|
|||
|
|
items = [f"{f['key']} — {(f.get('value') or '')[:50]}" for f in facts]
|
|||
|
|
parts.append("Из памяти: " + "; ".join(items) + ".")
|
|||
|
|
|
|||
|
|
tasks = scheduler_store.list_all()
|
|||
|
|
if tasks:
|
|||
|
|
first_name = tasks[0].get("name") or "task"
|
|||
|
|
parts.append(f"В расписании {len(tasks)}, ближайшее: {first_name}.")
|
|||
|
|
|
|||
|
|
if last_assistant_reply:
|
|||
|
|
snippet = " ".join(last_assistant_reply.split())[:120]
|
|||
|
|
parts.append("Я говорил: " + snippet)
|
|||
|
|
|
|||
|
|
if not parts:
|
|||
|
|
_say("Ничего недавно не происходило, сэр.")
|
|||
|
|
return
|
|||
|
|
_say(" ".join(parts))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Trivia ───────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
_TRIVIA_RU = [
|
|||
|
|
"Первый костюм Старк собрал в пещере в Афганистане. Десять рабочих недель, ноль вентиляции.",
|
|||
|
|
"Дуговый реактор изначально питал электромагнит, удерживающий осколки шрапнели у самого сердца.",
|
|||
|
|
"Палладий в первом реакторе медленно отравлял Старка. Решением стал новый, синтезированный отцом, элемент.",
|
|||
|
|
"Mark I весил около 200 килограммов и летал лишь однажды — на побег из плена.",
|
|||
|
|
"Mark III выкрашен в красно-золотой потому, что чистое золото оказалось мягче, чем хотелось.",
|
|||
|
|
"Mark V — складной костюм в чемодане. Идеальное оружие на званый ужин.",
|
|||
|
|
"Hulkbuster, Mark XLIV, был запущен совместно с Брюсом Беннером — на случай, если Халк выйдет из берегов.",
|
|||
|
|
"Я, сэр, появился в фильме «Железный человек» 2008 года. Меня озвучил Пол Беттани.",
|
|||
|
|
"Имя J.A.R.V.I.S. — отсылка к Эдвину Джарвису, дворецкому отца Тони, Говарда Старка.",
|
|||
|
|
"После событий «Эры Альтрона» программа J.A.R.V.I.S. была интегрирована в Vision.",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
_TRIVIA_EN = [
|
|||
|
|
"Stark built the first suit in a cave in Afghanistan. Ten weeks, no ventilation.",
|
|||
|
|
"The arc reactor originally powered an electromagnet keeping shrapnel away from his heart.",
|
|||
|
|
"Palladium in the first reactor was slowly poisoning him. The cure was a new element his father had synthesised.",
|
|||
|
|
"Mark I weighed roughly 200 kilograms and flew exactly once — for the escape.",
|
|||
|
|
"Mark III is red-and-gold because pure gold turned out to be too soft.",
|
|||
|
|
"Mark V folds into a briefcase. Useful at galas.",
|
|||
|
|
"I, sir, first appeared in Iron Man (2008). Paul Bettany voiced me.",
|
|||
|
|
"The name J.A.R.V.I.S. nods to Edwin Jarvis, butler to Tony's father Howard.",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_trivia(action=None, voice=None, lang: str = "ru") -> None:
|
|||
|
|
pool = _TRIVIA_EN if lang == "en" else _TRIVIA_RU
|
|||
|
|
_say(random.choice(pool))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── Routines ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_good_night(action=None, voice=None) -> None:
|
|||
|
|
"""Switch to 'sleep' profile, cancel one-shot timers, speak goodnight."""
|
|||
|
|
try:
|
|||
|
|
profiles_store.set_active("sleep")
|
|||
|
|
except (ValueError, KeyError):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
cancelled = 0
|
|||
|
|
for task in scheduler_store.list_all():
|
|||
|
|
name = task.get("name") or ""
|
|||
|
|
if name.startswith(("Таймер:", "Timer:", "Кухня:", "Cooking:")):
|
|||
|
|
if scheduler_store.remove(task.get("id", "")):
|
|||
|
|
cancelled += 1
|
|||
|
|
|
|||
|
|
lines = [
|
|||
|
|
"Спокойной ночи, сэр. Я остаюсь на связи.",
|
|||
|
|
"Доброй ночи, сэр. Системы переведены в тихий режим.",
|
|||
|
|
"Спите крепко, сэр. Утром доложу о всём важном.",
|
|||
|
|
"Хорошо, сэр. Не отвлекаю до утра.",
|
|||
|
|
]
|
|||
|
|
line = random.choice(lines)
|
|||
|
|
if cancelled > 0:
|
|||
|
|
line += f" Отменил {cancelled} одноразовых таймеров."
|
|||
|
|
_say(line)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_good_morning(action=None, voice=None) -> None:
|
|||
|
|
try:
|
|||
|
|
profiles_store.set_active("default")
|
|||
|
|
except (ValueError, KeyError):
|
|||
|
|
pass
|
|||
|
|
n_tasks = len(scheduler_store.list_all())
|
|||
|
|
lines = [
|
|||
|
|
"Доброе утро, сэр. Системы в порядке.",
|
|||
|
|
"Доброе утро. Готов к новому дню.",
|
|||
|
|
"С пробуждением, сэр. День в норме.",
|
|||
|
|
"Доброе утро, сэр. Кофе сам себя не сварит.",
|
|||
|
|
]
|
|||
|
|
line = random.choice(lines)
|
|||
|
|
if n_tasks > 0:
|
|||
|
|
line += f" В расписании {n_tasks}, скажите 'покажи расписание', чтобы услышать."
|
|||
|
|
else:
|
|||
|
|
line += " Расписание чистое."
|
|||
|
|
_say(line)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def do_coffee_break(action=None, voice=None) -> None:
|
|||
|
|
"""Schedule a 5-min check-in reminder. Banter pause is Rust-only — Python
|
|||
|
|
side doesn't have an idle-banter equivalent yet."""
|
|||
|
|
try:
|
|||
|
|
sched = scheduler_store.parse_schedule("in 5 minutes")
|
|||
|
|
except ValueError:
|
|||
|
|
_say("Не получилось поставить чек-ин.")
|
|||
|
|
return
|
|||
|
|
scheduler_store.add({
|
|||
|
|
"name": "Чек-ин после кофе",
|
|||
|
|
"schedule": sched,
|
|||
|
|
"action": {"type": "speak", "text": "Сэр, прошло пять минут. Вы вернулись?"},
|
|||
|
|
})
|
|||
|
|
lines = [
|
|||
|
|
"Хорошо, сэр. Возвращайтесь через пять.",
|
|||
|
|
"Тихо в эфире пять минут. Удачно вам.",
|
|||
|
|
"Пять минут — норма для кофе, не более.",
|
|||
|
|
]
|
|||
|
|
_say(random.choice(lines))
|