Compare commits
2 commits
5265cb0fbe
...
b3cfaa5171
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3cfaa5171 | ||
|
|
94edb73f26 |
7 changed files with 645 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -15,6 +15,8 @@ schedule.json
|
|||
macros.json
|
||||
llm_backend.txt
|
||||
llm_history.json
|
||||
active_profile.txt
|
||||
time_tracker.json
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
18
main.py
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
236
tests/test_wave68_handlers.py
Normal file
236
tests/test_wave68_handlers.py
Normal 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
278
wave68_handlers.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue