From 633850d0f713ce1c2f6f449050a03ba9b88f5ae7 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sun, 24 May 2026 23:12:04 +0300 Subject: [PATCH] feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_' 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). --- commands.yaml | 59 +++++++ extensions.py | 13 ++ main.py | 23 +++ tests/test_wave59_handlers.py | 152 ++++++++++++++++++ wave59_handlers.py | 289 ++++++++++++++++++++++++++++++++++ 5 files changed, 536 insertions(+) create mode 100644 tests/test_wave59_handlers.py create mode 100644 wave59_handlers.py diff --git a/commands.yaml b/commands.yaml index 96b971c..68aac0e 100644 --- a/commands.yaml +++ b/commands.yaml @@ -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} diff --git a/extensions.py b/extensions.py index e79b9fe..948e248 100644 --- a/extensions.py +++ b/extensions.py @@ -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) diff --git a/main.py b/main.py index a96a9f7..ec824fa 100644 --- a/main.py +++ b/main.py @@ -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 = '' diff --git a/tests/test_wave59_handlers.py b/tests/test_wave59_handlers.py new file mode 100644 index 0000000..655bd81 --- /dev/null +++ b/tests/test_wave59_handlers.py @@ -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] diff --git a/wave59_handlers.py b/wave59_handlers.py new file mode 100644 index 0000000..0b06cdf --- /dev/null +++ b/wave59_handlers.py @@ -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_"] 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)