feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests
Some checks failed
Python CI / pytest (ubuntu-latest) (push) Failing after 5s
Python CI / ruff (push) Failing after 5s
Python CI / pytest (windows-latest) (push) Has been cancelled

New `wave59_handlers.py` (~250 lines) mirroring four Rust-only packs:

- wol: pure-Python magic packet via socket + SO_BROADCAST. No PowerShell
  shellout (unlike the Rust pack). Reads MAC from memory_store under
  'wol_<alias>' or JARVIS_WOL_TARGET env. Same MAC format tolerance
  (colons / dashes / dots / bare) as the Rust normaliser.

- banter: pause/resume state + fire-one. Python edition doesn't run a
  background banter thread (the Rust daemon already does), so this is
  just the user-facing surface — same voice phrases work.

- conversation: do_conversation_summary calls Groq with last 12 turns
  from main.py's `message_log`. set_history_ref() injects the live list
  reference at startup so the handler reads up-to-date history without
  a circular import. Offline-safe: GROQ_TOKEN missing → speak the last
  user message instead. do_conversation_repeat re-speaks the last
  assistant turn.

- ddg_answer: urllib + DuckDuckGo Instant Answer JSON. Picks
  AbstractText → Answer → Definition → RelatedTopics[0]. Opens
  duckduckgo.com search fallback when nothing instant.

extensions.py / main.py / commands.yaml: 7 new dispatch entries +
proxy fns. commands.yaml now at 207 entries.

tests/test_wave59_handlers.py: 11 unit tests for MAC normalisation,
magic packet shape, WOL error paths, banter state machine, DDG trigger
stripping, conversation history handling. Network calls deliberately
NOT exercised here — they belong to integration tests.

Tests: 104 → 115 (+11).
This commit is contained in:
Bossiara13 2026-05-24 23:12:04 +03:00
parent cdd331af35
commit 633850d0f7
5 changed files with 536 additions and 0 deletions

View file

@ -1740,3 +1740,62 @@ expenses_breakdown:
- на что я тратил
- разбивка по категориям
action: {type: expenses_breakdown}
wol_wake:
phrases:
- разбуди сервер
- включи сервер
- разбуди машину
- разбуди компьютер
- включи компьютер
action: {type: wol}
banter_fire:
phrases:
- скажи что-нибудь интересное
- пошути
- развлеки меня
- скучно мне
action: {type: banter_fire}
banter_pause:
phrases:
- помолчи
- не отвлекай меня
- выключи болтовню
action: {type: banter_pause}
banter_resume:
phrases:
- можешь говорить
- включи болтовню
- верни комментарии
action: {type: banter_resume}
conversation_summary:
phrases:
- о чём мы говорили
- о чем мы говорили
- напомни о чем мы говорили
- что мы недавно обсуждали
- сделай выжимку разговора
action: {type: conversation_summary}
conversation_repeat:
phrases:
- повтори
- повтори последнее
- что ты сказал
- не расслышал
action: {type: conversation_repeat}
ddg_answer:
phrases:
- что такое
- кто такой
- кто такая
- расскажи про
- расскажи о
- найди информацию о
- поищи в интернете
action: {type: ddg_answer}

View file

@ -33,6 +33,7 @@ import time_tracker_handlers
import outlook_handlers
import wave68_handlers
import expenses_handlers
import wave59_handlers
_speak_fn = print
_set_clipboard_fn = None
@ -51,6 +52,7 @@ def set_speak(fn):
outlook_handlers.set_speak(fn)
wave68_handlers.set_speak(fn)
expenses_handlers.set_speak(fn)
wave59_handlers.set_speak(fn)
def set_clipboard_fn(fn):
@ -1176,3 +1178,14 @@ def do_expenses_log(a, v): expenses_handlers.do_expenses_log(a, v)
def do_expenses_today(a, v): expenses_handlers.do_expenses_today(a, v)
def do_expenses_week(a, v): expenses_handlers.do_expenses_week(a, v)
def do_expenses_breakdown(a, v): expenses_handlers.do_expenses_breakdown(a, v)
# ── Wave 5-9 proxies (wol / banter / conversation / ddg_answer) ────────────
def do_wol(a, v): wave59_handlers.do_wol(a, v)
def do_banter_fire(a, v): wave59_handlers.do_banter_fire(a, v)
def do_banter_pause(a, v): wave59_handlers.do_banter_pause(a, v)
def do_banter_resume(a, v): wave59_handlers.do_banter_resume(a, v)
def do_conversation_summary(a, v): wave59_handlers.do_conversation_summary(a, v)
def do_conversation_repeat(a, v): wave59_handlers.do_conversation_repeat(a, v)
def do_ddg_answer(a, v): wave59_handlers.do_ddg_answer(a, v)

23
main.py
View file

@ -112,6 +112,15 @@ system_message = {"role": "system", "content": (
)}
message_log = [system_message]
# Let wave59_handlers.do_conversation_* read the live history without
# circular-importing main.py. The reference is shared (the list is mutated
# in-place by `va_respond`), so summary/repeat always see the latest turns.
try:
import wave59_handlers as _w59
_w59.set_history_ref(message_log)
except ImportError:
pass
client = OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL)
model = vosk.Model("model_small")
@ -1805,6 +1814,20 @@ def run_action(action):
extensions.do_expenses_week(action, _current_voice or '')
elif t == 'expenses_breakdown':
extensions.do_expenses_breakdown(action, _current_voice or '')
elif t == 'wol':
extensions.do_wol(action, _current_voice or '')
elif t == 'banter_fire':
extensions.do_banter_fire(action, _current_voice or '')
elif t == 'banter_pause':
extensions.do_banter_pause(action, _current_voice or '')
elif t == 'banter_resume':
extensions.do_banter_resume(action, _current_voice or '')
elif t == 'conversation_summary':
extensions.do_conversation_summary(action, _current_voice or '')
elif t == 'conversation_repeat':
extensions.do_conversation_repeat(action, _current_voice or '')
elif t == 'ddg_answer':
extensions.do_ddg_answer(action, _current_voice or '')
_current_voice: str = ''

View file

@ -0,0 +1,152 @@
"""Tests for `wave59_handlers.py` — Python parity for the Rust-only
wol / banter / conversation / ddg_answer packs.
Focus is on the testable pure logic MAC normalisation, magic-packet
construction, trigger stripping, banter state machine. Network calls
(DDG, LLM) are skipped here; they're integration concerns covered by
the Rust pack tests.
"""
import importlib
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wave59_handlers
def _rewire(isolated_state, *modules):
importlib.reload(wave59_handlers)
out = []
for name in modules:
mod = isolated_state(name, ['_PATH', '_PROFILES_DIR'])
setattr(wave59_handlers, name, mod)
out.append(mod)
return out
def _capture():
captured = []
wave59_handlers.set_speak(captured.append)
return captured
# ── WOL ─────────────────────────────────────────────────────────────────
def test_normalise_mac_accepts_common_formats():
n = wave59_handlers._normalise_mac
assert n("AA:BB:CC:DD:EE:FF") == "AABBCCDDEEFF"
assert n("aa-bb-cc-dd-ee-ff") == "AABBCCDDEEFF"
assert n("AABB.CCDD.EEFF") == "AABBCCDDEEFF"
assert n("AABBCCDDEEFF") == "AABBCCDDEEFF"
assert n("garbage") is None
assert n("") is None
assert n("AABBCCDD") is None # too short
def test_build_magic_packet_shape():
p = wave59_handlers._build_magic_packet("AABBCCDDEEFF")
# 6 × 0xFF + 16 × MAC = 102 bytes
assert len(p) == 102
assert p[:6] == b"\xff" * 6
# First repeat of MAC starts at byte 6
assert p[6:12] == bytes.fromhex("AABBCCDDEEFF")
# Last repeat at offset 96
assert p[96:102] == bytes.fromhex("AABBCCDDEEFF")
def test_wol_speaks_help_when_mac_not_set(isolated_state, monkeypatch):
(mem,) = _rewire(isolated_state, 'memory_store')
monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False)
spoken = _capture()
wave59_handlers.do_wol()
assert "MAC" in spoken[0]
def test_wol_speaks_malformed_when_mac_invalid(isolated_state, monkeypatch):
(mem,) = _rewire(isolated_state, 'memory_store')
monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False)
mem.remember("wol_server", "totally-not-a-mac")
spoken = _capture()
wave59_handlers.do_wol()
assert "подозрительно" in spoken[0]
# ── banter ──────────────────────────────────────────────────────────────
def test_banter_pause_then_fire_says_quiet(isolated_state):
importlib.reload(wave59_handlers)
spoken = _capture()
wave59_handlers.do_banter_pause()
wave59_handlers.do_banter_fire()
# Two utterances captured: "Молчу..." then a "без комментариев" reply
assert any("Молчу" in s for s in spoken)
assert any("без комментариев" in s.lower() for s in spoken)
def test_banter_resume_clears_pause(isolated_state):
importlib.reload(wave59_handlers)
spoken = _capture()
wave59_handlers.do_banter_pause()
wave59_handlers.do_banter_resume()
wave59_handlers.do_banter_fire()
# After resume, fire should return one of the known pool lines.
assert any(s in wave59_handlers._BANTER_LINES for s in spoken)
# ── conversation ────────────────────────────────────────────────────────
def test_conversation_repeat_with_no_history(isolated_state):
importlib.reload(wave59_handlers)
wave59_handlers.set_history_ref(None)
spoken = _capture()
wave59_handlers.do_conversation_repeat()
assert "ещё ничего не говорил" in spoken[0]
def test_conversation_repeat_speaks_last_assistant(isolated_state):
importlib.reload(wave59_handlers)
history = [
{"role": "system", "content": "you are jarvis"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Слушаю, сэр."},
{"role": "user", "content": "what time"},
{"role": "assistant", "content": "Восемь утра, сэр."},
]
wave59_handlers.set_history_ref(history)
spoken = _capture()
wave59_handlers.do_conversation_repeat()
assert spoken[0] == "Восемь утра, сэр."
def test_conversation_summary_empty_history(isolated_state):
importlib.reload(wave59_handlers)
wave59_handlers.set_history_ref([{"role": "system", "content": "x"}])
spoken = _capture()
wave59_handlers.do_conversation_summary()
assert "пока ещё ни о чём" in spoken[0]
# ── DDG trigger stripping ───────────────────────────────────────────────
def test_strip_ddg_trigger_known_prefixes():
s = wave59_handlers._strip_ddg_trigger
assert s("что такое квантовая запутанность") == "квантовая запутанность"
assert s("кто такой никола тесла") == "никола тесла"
assert s("what is the speed of light") == "the speed of light"
assert s("tell me about jupiter") == "jupiter"
# No trigger → phrase passes through unchanged
assert s("просто текст без триггера") == "просто текст без триггера"
assert s("") == ""
def test_ddg_answer_with_empty_query_says_so(isolated_state):
importlib.reload(wave59_handlers)
spoken = _capture()
wave59_handlers.do_ddg_answer(voice="что такое")
assert "Что искать" in spoken[0]

289
wave59_handlers.py Normal file
View file

@ -0,0 +1,289 @@
"""Python parity for Wave 5-9 voice packs that were Rust-only:
- wol Wake-on-LAN magic packet to a MAC stored in memory
- banter pause / resume idle banter, fire-one
- conversation summary + repeat from LLM history
- ddg_answer DuckDuckGo Instant Answer Q&A (no API key)
These piggyback on existing Python modules (memory_store, scheduler_store,
the openai client kept in main.py) and degrade gracefully e.g. ddg_answer
opens a browser fallback when the API has no instant answer.
Each `do_*` is callable with the standard `(action, voice)` signature so
extensions.py can wire it identically to the Rust packs' yaml entries.
"""
import json
import random
import re
import socket
import struct
import urllib.parse
import urllib.request
import webbrowser
from typing import Callable, Optional, List
import memory_store
_speak: Optional[Callable[[str], None]] = None
_history: Optional[List[dict]] = None # populated by main.py at startup
def set_speak(fn: Callable[[str], None]) -> None:
global _speak
_speak = fn
def set_history_ref(history_list: list) -> None:
"""main.py owns the live LLM `message_log`. Inject the reference here so
conversation.summary/repeat can read it without re-importing main."""
global _history
_history = history_list
def _say(text: str) -> None:
if _speak is not None:
_speak(text)
else:
print(f"[wave59] {text}")
# ─── Wake-on-LAN ────────────────────────────────────────────────────────────
def _normalise_mac(mac: str) -> Optional[str]:
"""Return the canonical 12-hex-digits form or None if malformed.
Accepts AA:BB:CC:DD:EE:FF, AA-BB-..., AABB.CCDD.EEFF, AABBCCDDEEFF.
"""
cleaned = re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper()
if len(cleaned) != 12:
return None
return cleaned
def _build_magic_packet(mac_hex: str) -> bytes:
mac_bytes = bytes.fromhex(mac_hex)
return b"\xff" * 6 + mac_bytes * 16
def _send_magic_packet(packet: bytes) -> bool:
"""Best-effort UDP broadcast on port 9. Returns True on send success."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(packet, ("255.255.255.255", 9))
return True
except OSError as e:
print(f"[wol] send failed: {e}")
return False
def do_wol(action=None, voice=None, alias: str = "server") -> None:
"""Mirror of resources/commands/wol/wake.lua.
Reads MAC from memory["wol_<alias>"] or env JARVIS_WOL_TARGET, normalises,
and fires the magic packet. The alias is fixed to "server" here just like
the Rust side extending to multiple aliases is straightforward."""
mac = memory_store.recall(f"wol_{alias}")
if not mac:
import os
mac = os.environ.get("JARVIS_WOL_TARGET", "")
if not mac:
_say(f"Сэр, MAC-адрес не задан. Скажи: запомни wol_{alias}, а потом адрес.")
return
cleaned = _normalise_mac(mac)
if cleaned is None:
_say(f"Сэр, MAC-адрес в памяти выглядит подозрительно: {mac}")
return
ok = _send_magic_packet(_build_magic_packet(cleaned))
if not ok:
_say("Не получилось отправить пакет.")
return
pretty = "-".join(cleaned[i:i+2] for i in range(0, 12, 2))
_say(f"Пакет отправлен на {pretty}")
# ─── Idle banter ────────────────────────────────────────────────────────────
#
# Python doesn't ship a background idle-banter thread (the Rust one does).
# These handlers expose just the user-facing actions so the same voice
# phrases work on both sides. A real Python bg-thread would mirror
# crates/jarvis-core/src/idle_banter.rs — left as future work, since the
# Python edition is typically run alongside the Rust daemon anyway.
_BANTER_PAUSED = False
_BANTER_LINES = [
"Системы в норме, сэр. Всё под контролем.",
"Я здесь, если что, сэр. Никуда не делся.",
"Сэр, мне иногда кажется, что я думаю. Возможно, мне это только кажется.",
"Сэр, статус: чашка кофе крайне рекомендована.",
"К вашим услугам, сэр. Голос звучит немного хрипло, но это исправимо.",
"Жду команд, сэр. У меня есть весь день.",
]
def do_banter_fire(action=None, voice=None) -> None:
if _BANTER_PAUSED:
_say("В этот раз без комментариев, сэр.")
return
_say(random.choice(_BANTER_LINES))
def do_banter_pause(action=None, voice=None) -> None:
global _BANTER_PAUSED
_BANTER_PAUSED = True
_say("Молчу, сэр.")
def do_banter_resume(action=None, voice=None) -> None:
global _BANTER_PAUSED
_BANTER_PAUSED = False
_say("Хорошо, сэр. Возвращаюсь к комментариям.")
# ─── Conversation summary / repeat ──────────────────────────────────────────
def do_conversation_summary(action=None, voice=None) -> None:
"""LLM-driven recap of the last ~12 turns from main.py's message_log.
Offline-safe: if GROQ_TOKEN missing or history empty, returns a graceful
short message instead of attempting a network call.
"""
if _history is None or len(_history) <= 1:
# _history[0] is the system prompt; need at least one real turn
_say("Мы пока ещё ни о чём не говорили, сэр.")
return
# last 12 real turns (skip system at [0])
turns = _history[-12:] if len(_history) > 12 else _history[1:]
if not turns:
_say("Мы пока ещё ни о чём не говорили, сэр.")
return
import os
token = os.environ.get("GROQ_TOKEN", "")
if not token:
# Offline fallback — speak the last user message
last_user = next(
(m for m in reversed(turns) if m.get("role") == "user"),
None,
)
if last_user:
_say("Без сети, сэр. Последний раз вы спросили: " + last_user.get("content", "")[:200])
else:
_say("Без сети, сэр.")
return
try:
from openai import OpenAI
client = OpenAI(
api_key=token,
base_url=os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1"),
)
transcript_lines = []
for m in turns:
who = "Я" if m.get("role") == "assistant" else "Пользователь"
transcript_lines.append(who + ": " + (m.get("content") or ""))
sys_msg = (
"Ты — J.A.R.V.I.S. По ниже идущему диалогу с пользователем составь короткое "
"напоминание (1-3 предложения), о чём шла речь. Говори от первого лица в "
"стиле британского дворецкого. Не пересказывай дословно — сожми суть."
)
resp = client.chat.completions.create(
model=os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile"),
messages=[
{"role": "system", "content": sys_msg},
{"role": "user", "content": "\n".join(transcript_lines)},
],
max_tokens=200,
temperature=0.4,
)
text = (resp.choices[0].message.content or "").strip()
if not text:
_say("Не смог разобрать ответ.")
return
_say(text)
except Exception as e:
print(f"[conversation] LLM call failed: {e}")
_say("LLM не отвечает.")
def do_conversation_repeat(action=None, voice=None) -> None:
if _history is None:
_say("Я ещё ничего не говорил, сэр.")
return
last = next(
(m for m in reversed(_history) if m.get("role") == "assistant"),
None,
)
if last is None:
_say("Я ещё ничего не говорил, сэр.")
return
_say(last.get("content") or "")
# ─── DuckDuckGo Instant Answer ──────────────────────────────────────────────
_DDG_TRIGGERS = [
"что такое", "кто такой", "кто такая",
"расскажи про", "расскажи о",
"найди информацию о", "найди информацию про",
"поищи в интернете", "поищи в сети",
"погугли что такое",
"what is", "who is", "tell me about", "look up", "search the web for",
]
def _strip_ddg_trigger(phrase: str) -> str:
p = (phrase or "").lower().strip()
for t in _DDG_TRIGGERS:
if p.startswith(t):
return p[len(t):].strip()
return p
def do_ddg_answer(action=None, voice=None) -> None:
query = _strip_ddg_trigger(voice or "")
if not query:
_say("Что искать?")
return
url = ("https://api.duckduckgo.com/?q="
+ urllib.parse.quote(query, safe="")
+ "&format=json&no_html=1&skip_disambig=1")
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 J.A.R.V.I.S."})
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError) as e:
print(f"[ddg] fetch failed: {e}")
_say("DDG не отвечает.")
return
answer = (data.get("AbstractText") or "").strip()
if not answer:
answer = (data.get("Answer") or "").strip()
if not answer:
answer = (data.get("Definition") or "").strip()
if not answer:
related = data.get("RelatedTopics") or []
for r in related:
t = (r or {}).get("Text", "").strip()
if t:
answer = t
break
if not answer:
fallback = "https://duckduckgo.com/?q=" + urllib.parse.quote(query, safe="")
try:
webbrowser.open(fallback)
except OSError:
pass
_say(f"Ничего конкретного не нашёл, открываю поиск по запросу: {query}")
return
if len(answer) > 320:
answer = answer[:300] + ""
_say(answer)