feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests
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:
parent
cdd331af35
commit
633850d0f7
5 changed files with 536 additions and 0 deletions
289
wave59_handlers.py
Normal file
289
wave59_handlers.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue