J.A.R.V.I.S-py/wave68_handlers.py

279 lines
12 KiB
Python
Raw Normal View History

"""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))