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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
23
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 = ''
|
||||
|
|
|
|||
152
tests/test_wave59_handlers.py
Normal file
152
tests/test_wave59_handlers.py
Normal 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
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