feat: Python parity for Wave 6-8 — memory admin / cooking / leftoff / trivia / routines

New module `wave68_handlers.py` (~270 lines) covering the Rust packs that
were Rust-only after Waves 6-8:

  - memory_admin: do_memory_count/list/wipe (with full RU plural decl.)
  - cooking: do_cooking + reusable match_recipe(phrase) for the 15-dish
    preset table. Schedules a "ready!" reminder via scheduler_store.
  - leftoff: do_leftoff stitches memory + scheduled tasks + last reply
    into a one-paragraph recap. Offline.
  - trivia: do_trivia random one-liner from RU/EN MCU pools.
  - routines: do_good_night (sleep profile + cancel one-shots),
    do_good_morning (default profile + agenda hint),
    do_coffee_break (5-min check-in via scheduler).

memory_store: new clear_all() returning removed count, atomic via _save.

extensions.py: imports + set_speak wiring + 9 proxy do_* fns.
main.py: 9 new dispatch elifs.
commands.yaml: 9 new entries (now 196 total).

tests/test_wave68_handlers.py: 15 unit tests covering each handler.
Test helper `_rewire()` re-imports state modules via the existing
isolated_state fixture then re-binds the wave68_handlers module-level
refs — fixes the gotcha where module-level imports cache the OLD
memory_store / scheduler_store instances from process startup.

Tests: 81 → 96 (+15).
This commit is contained in:
Bossiara13 2026-05-16 14:55:47 +03:00
parent 5265cb0fbe
commit 94edb73f26
7 changed files with 644 additions and 0 deletions

1
active_profile.txt Normal file
View file

@ -0,0 +1 @@
default

View file

@ -1624,3 +1624,88 @@ outlook_summarize_inbox:
- сделай саммари инбокса
- что нового в почте
action: {type: outlook_summarize_inbox}
memory_count:
phrases:
- сколько фактов помнишь
- сколько ты помнишь
- сколько у тебя в памяти
- размер памяти
action: {type: memory_count}
memory_list:
phrases:
- выгрузи список фактов
- покажи что помнишь
- перечисли что помнишь
- что у тебя в памяти
action: {type: memory_list}
memory_wipe:
phrases:
- точно забудь все
- точно забудь всё
- сотри всю память
action: {type: memory_wipe}
cooking_timer:
phrases:
- поставь чайник
- варю яйца
- варю макароны
- варю пасту
- жарю омлет
- жарю яичницу
- варю кашу
- варю рис
- варю гречку
- варю картошку
- запекаю курицу
- пеку пиццу
- завариваю чай
- завариваю кофе
- завариваю френч-пресс
action: {type: cooking}
leftoff:
phrases:
- на чём я остановился
- на чем я остановился
- напомни что делал
- что я делал
- где я остановился
- продолжи откуда я
action: {type: leftoff}
trivia_iron_man:
phrases:
- расскажи про железного человека
- факт про железного человека
- интересное про старка
- марвел факт
action: {type: trivia}
routines_good_night:
phrases:
- спокойной ночи
- иду спать
- пока я в кровати
- ночной режим
- выключи всё на ночь
action: {type: good_night}
routines_good_morning:
phrases:
- доброе утро джарвис
- доброе утро
- я проснулся
- начинаем день
action: {type: good_morning}
routines_coffee_break:
phrases:
- иду за кофе
- перерыв на кофе
- перерыв на чай
- отойду минут на пять
action: {type: coffee_break}

View file

@ -31,6 +31,7 @@ import wave1_handlers
import personality_handlers
import time_tracker_handlers
import outlook_handlers
import wave68_handlers
_speak_fn = print
_set_clipboard_fn = None
@ -47,6 +48,7 @@ def set_speak(fn):
personality_handlers.set_speak(fn)
time_tracker_handlers.set_speak(fn)
outlook_handlers.set_speak(fn)
wave68_handlers.set_speak(fn)
def set_clipboard_fn(fn):
@ -1151,3 +1153,16 @@ def do_outlook_unread_count(a, v): outlook_handlers.do_outlook_unread_count(a, v
def do_outlook_latest(a, v): outlook_handlers.do_outlook_latest(a, v)
def do_outlook_send_clipboard(a, v): outlook_handlers.do_outlook_send_clipboard(a, v)
def do_outlook_summarize_inbox(a, v): outlook_handlers.do_outlook_summarize_inbox(a, v)
# ── Wave 6-8 proxies (to wave68_handlers) ──────────────────────────────────
def do_memory_count(a, v): wave68_handlers.do_memory_count(a, v)
def do_memory_list(a, v): wave68_handlers.do_memory_list(a, v)
def do_memory_wipe(a, v): wave68_handlers.do_memory_wipe(a, v)
def do_cooking(a, v): wave68_handlers.do_cooking(a, v)
def do_leftoff(a, v): wave68_handlers.do_leftoff(a, v)
def do_trivia(a, v): wave68_handlers.do_trivia(a, v)
def do_good_night(a, v): wave68_handlers.do_good_night(a, v)
def do_good_morning(a, v): wave68_handlers.do_good_morning(a, v)
def do_coffee_break(a, v): wave68_handlers.do_coffee_break(a, v)

18
main.py
View file

@ -1717,6 +1717,24 @@ def run_action(action):
extensions.do_outlook_send_clipboard(action, _current_voice or '')
elif t == 'outlook_summarize_inbox':
extensions.do_outlook_summarize_inbox(action, _current_voice or '')
elif t == 'memory_count':
extensions.do_memory_count(action, _current_voice or '')
elif t == 'memory_list':
extensions.do_memory_list(action, _current_voice or '')
elif t == 'memory_wipe':
extensions.do_memory_wipe(action, _current_voice or '')
elif t == 'cooking':
extensions.do_cooking(action, _current_voice or '')
elif t == 'leftoff':
extensions.do_leftoff(action, _current_voice or '')
elif t == 'trivia':
extensions.do_trivia(action, _current_voice or '')
elif t == 'good_night':
extensions.do_good_night(action, _current_voice or '')
elif t == 'good_morning':
extensions.do_good_morning(action, _current_voice or '')
elif t == 'coffee_break':
extensions.do_coffee_break(action, _current_voice or '')
_current_voice: str = ''

View file

@ -96,6 +96,17 @@ def forget(key: str) -> bool:
return False
def clear_all() -> int:
"""Wipe every fact. Returns count of removed entries. Atomic via _save."""
_load()
n = len(_store)
if n == 0:
return 0
_store.clear()
_save()
return n
def search(query: str, limit: int = 5) -> list[dict]:
_load()
nq = _normalize_key(query)

View file

@ -0,0 +1,236 @@
"""Tests for `wave68_handlers.py` — Python parity for Wave 6-8 Rust packs.
Each test captures speech via `set_speak(callback)` so we can assert on what
J.A.R.V.I.S. would have said. Scheduler / memory state lives in tmp_path via
the `isolated_state` fixture from conftest.py.
Implementation gotcha: `isolated_state` re-imports `memory_store` /
`scheduler_store` etc with `_PATH` pointing at the per-test tmpdir, but the
already-imported `wave68_handlers` keeps a reference to the OLD modules.
`_rewire(...)` below re-binds those refs so handlers hit the tmp stores.
"""
import importlib
import os
import sys
# Make project root importable.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wave68_handlers
def _rewire(isolated_state, *modules):
"""Re-import each named state module via `isolated_state`, then rebind
the wave68_handlers reference so the handler picks up the tmp_path
instance. Returns the list of (re-imported) modules.
"""
importlib.reload(wave68_handlers)
out = []
for name in modules:
mod = isolated_state(name, ['_PATH', '_PROFILES_DIR'])
setattr(wave68_handlers, name, mod)
out.append(mod)
return out
def _capture_speak():
captured = []
wave68_handlers.set_speak(captured.append)
return captured
# ── memory admin ─────────────────────────────────────────────────────────
def test_memory_count_uses_russian_plural(isolated_state):
(mem,) = _rewire(isolated_state, 'memory_store')
spoken = _capture_speak()
wave68_handlers.do_memory_count()
assert "0 фактов" in spoken[0]
mem.remember('любимый чай', 'улун')
spoken.clear()
wave68_handlers.do_memory_count()
assert "1 факт" in spoken[0]
assert "факта" not in spoken[0] # singular, not "2 факта"
mem.remember('питомец', 'собака')
mem.remember('город', 'москва')
spoken.clear()
wave68_handlers.do_memory_count()
assert "3 факта" in spoken[0] # 2-4 → "факта"
for i in range(2):
mem.remember(f'k{i}', f'v{i}')
spoken.clear()
wave68_handlers.do_memory_count()
assert "5 фактов" in spoken[0] # 5+ → "фактов"
def test_memory_list_speaks_empty_state(isolated_state):
_rewire(isolated_state, 'memory_store')
spoken = _capture_speak()
wave68_handlers.do_memory_list()
assert "пуст" in spoken[0].lower()
def test_memory_list_shows_recent_first(isolated_state):
(mem,) = _rewire(isolated_state, 'memory_store')
# Inject pre-aged record directly so timestamps are distinct without
# waiting on the clock — `remember` uses wall-clock seconds and two
# back-to-back calls in tests routinely tie.
mem._load()
mem._store['старый'] = {
'key': 'старый', 'value': 'факт',
'created_at': 100, 'last_used_at': 100, 'use_count': 0,
}
mem._store['новый'] = {
'key': 'новый', 'value': 'факт',
'created_at': 200, 'last_used_at': 200, 'use_count': 0,
}
spoken = _capture_speak()
wave68_handlers.do_memory_list()
text = spoken[0]
# Both must appear, with новый (last_used_at=200) before старый (=100)
assert 'новый' in text
assert 'старый' in text
assert text.index('новый') < text.index('старый')
def test_memory_wipe_returns_count(isolated_state):
(mem,) = _rewire(isolated_state, 'memory_store')
mem.remember('a', '1')
mem.remember('b', '2')
mem.remember('c', '3')
spoken = _capture_speak()
wave68_handlers.do_memory_wipe()
assert "3" in spoken[0]
assert len(mem.all_facts()) == 0
def test_memory_wipe_on_empty_is_polite(isolated_state):
_rewire(isolated_state, 'memory_store')
spoken = _capture_speak()
wave68_handlers.do_memory_wipe()
assert "пуста" in spoken[0]
# ── cooking ─────────────────────────────────────────────────────────────
def test_match_recipe_finds_substrings():
# Direct unit test on the matcher — no scheduler side effects.
assert wave68_handlers.match_recipe("варю макароны") == (10, "макароны")
assert wave68_handlers.match_recipe("ставлю чайник") == (4, "чайник")
assert wave68_handlers.match_recipe("жарю омлет") == (5, "омлет")
assert wave68_handlers.match_recipe("какая сегодня погода") is None
assert wave68_handlers.match_recipe("") is None
def test_cooking_schedules_a_task(isolated_state):
(sched,) = _rewire(isolated_state, 'scheduler_store')
spoken = _capture_speak()
wave68_handlers.do_cooking(voice="варю пасту 200 грамм")
tasks = sched.list_all()
assert len(tasks) == 1
assert "паст" in tasks[0]['name'].lower()
assert "10" in spoken[0]
def test_cooking_unmatched_phrase_says_so(isolated_state):
_rewire(isolated_state, 'scheduler_store')
spoken = _capture_speak()
wave68_handlers.do_cooking(voice="какая погода в москве")
assert "не понял" in spoken[0].lower()
# ── leftoff ─────────────────────────────────────────────────────────────
def test_leftoff_pulls_memory_and_tasks(isolated_state):
mem, sched = _rewire(isolated_state, 'memory_store', 'scheduler_store')
mem.remember('текущий проект', 'jarvis рефакторинг')
sched.add({
"name": "Daily standup",
"schedule": sched.parse_schedule("daily 09:00"),
"action": {"type": "speak", "text": "standup"},
})
spoken = _capture_speak()
wave68_handlers.do_leftoff(last_assistant_reply="Готовлю кофе, сэр.")
text = spoken[0]
assert "текущий проект" in text
assert "Daily standup" in text
assert "Готовлю кофе" in text
def test_leftoff_empty_is_polite(isolated_state):
_rewire(isolated_state, 'memory_store', 'scheduler_store')
spoken = _capture_speak()
wave68_handlers.do_leftoff()
assert "ничего" in spoken[0].lower()
# ── trivia ──────────────────────────────────────────────────────────────
def test_trivia_returns_one_known_line():
spoken = _capture_speak()
wave68_handlers.do_trivia(lang="ru")
assert spoken[0] in wave68_handlers._TRIVIA_RU
def test_trivia_english_pool():
spoken = _capture_speak()
wave68_handlers.do_trivia(lang="en")
assert spoken[0] in wave68_handlers._TRIVIA_EN
# ── routines ────────────────────────────────────────────────────────────
def test_good_night_cancels_one_shot_timers(isolated_state):
sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store')
# Pre-populate: one one-shot ("Таймер:") and one daily (should stay)
sched.add({
"name": "Таймер: яичница",
"schedule": sched.parse_schedule("in 5 minutes"),
"action": {"type": "speak", "text": "ready"},
})
sched.add({
"name": "Daily briefing",
"schedule": sched.parse_schedule("daily 09:00"),
"action": {"type": "speak", "text": "morning"},
})
spoken = _capture_speak()
wave68_handlers.do_good_night()
remaining = [t['name'] for t in sched.list_all()]
assert "Daily briefing" in remaining
assert not any("Таймер:" in n for n in remaining)
assert "1" in spoken[0] # cancelled-count appears in message
def test_good_morning_reports_clear_or_busy_schedule(isolated_state):
sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store')
spoken = _capture_speak()
wave68_handlers.do_good_morning()
assert "чистое" in spoken[0].lower() or "clear" in spoken[0].lower()
sched.add({
"name": "Task",
"schedule": sched.parse_schedule("daily 10:00"),
"action": {"type": "speak", "text": "x"},
})
spoken.clear()
wave68_handlers.do_good_morning()
assert "1" in spoken[0]
def test_coffee_break_creates_5min_checkin(isolated_state):
(sched,) = _rewire(isolated_state, 'scheduler_store')
spoken = _capture_speak()
wave68_handlers.do_coffee_break()
tasks = sched.list_all()
assert len(tasks) == 1
assert "Чек-ин" in tasks[0]['name']
assert spoken[0] # non-empty

278
wave68_handlers.py Normal file
View file

@ -0,0 +1,278 @@
"""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))