feat+test: pytest suite + Now Playing + World Clock + Daily Quote packs (154 commands)
Adds first formal test suite for Python fork — 42 tests covering memory_store,
profiles_store, scheduler_store, macros_store, llm_backend. All pass.
Tests (tests/, ~290 lines)
conftest.py — `isolated_state` fixture: rewires _PATH/_PROFILES_DIR etc to
a per-test tmp_path so concurrent runs don't touch real data.
test_memory_store.py — 9 tests: remember/recall/forget round-trip,
search by key/value, limit/empty query,
persistence across reload, build_llm_context
test_profiles_store.py — 7 tests: seeded defaults, set_active persistence,
allows_command logic (default/work/driving)
test_scheduler_store.py — 12 tests: parser (daily/at/every/in, ru units,
bad input), add/remove/clear/remove_by_text,
_next_fire for each schedule kind
test_macros_store.py — 8 tests: is_macro_control filter, start/save
round-trip, control-phrase filtering, list_names
sorted, replay unknown raises
test_llm_backend.py — 6 tests: parse_backend aliases (ru + en),
persist round-trip, auto-detect logic
Run: cd /c/Jarvis/python && python -m pytest tests/
Three new fun packs (commands.yaml: 151 → 154)
now_playing — Windows Media Session API via PowerShell
"что играет" / "что за песня" / "какой трек"
Works with Spotify, YouTube, Foobar, Yandex Music, anything
that exposes SMTC (Win10 1803+).
world_clock — worldtimeapi.org (free, no key)
"сколько времени в Токио" / "время в Лондоне"
21 Russian + world cities pre-mapped to IANA timezones.
daily_quote — zenquotes.io (free, no key) + LLM translation
"цитата дня" / "вдохнови меня"
Fetches English quote, translates to Russian via active LLM
(Groq or Ollama), speaks "text — author."
Pack count: 151 → 154. Tests: 42 pytest + ast.parse + yaml.safe_load.
This commit is contained in:
parent
8d3da4ea06
commit
4e62049a98
10 changed files with 637 additions and 0 deletions
154
extensions.py
154
extensions.py
|
|
@ -913,6 +913,160 @@ def do_llm_status(action, voice):
|
|||
_speak("LLM не настроен.")
|
||||
|
||||
|
||||
# ── Now Playing (Windows SMTC) ──────────────────────────────────────────────
|
||||
|
||||
_NOW_PLAYING_PS = """
|
||||
[Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager,Windows.Media.Control,ContentType=WindowsRuntime] | Out-Null;
|
||||
$mgr = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync();
|
||||
while ($mgr.Status -eq 0) { Start-Sleep -Milliseconds 50 };
|
||||
$session = $mgr.GetResults().GetCurrentSession();
|
||||
if (-not $session) { Write-Output 'NONE'; exit };
|
||||
$propsTask = $session.TryGetMediaPropertiesAsync();
|
||||
while ($propsTask.Status -eq 0) { Start-Sleep -Milliseconds 50 };
|
||||
$props = $propsTask.GetResults();
|
||||
Write-Output ("{0}|{1}" -f $props.Artist, $props.Title)
|
||||
""".strip().replace('\n', '; ')
|
||||
|
||||
|
||||
def do_now_playing(action, voice):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', _NOW_PLAYING_PS],
|
||||
capture_output=True, text=True, encoding='utf-8', timeout=8,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[ext] now_playing: {exc}")
|
||||
_speak("Не получилось получить данные.")
|
||||
return
|
||||
|
||||
out = (res.stdout or '').strip()
|
||||
if out == 'NONE' or not out:
|
||||
_speak("Сейчас ничего не играет.")
|
||||
return
|
||||
if '|' not in out:
|
||||
_speak("Не понял ответ системы.")
|
||||
return
|
||||
artist, _, title = out.partition('|')
|
||||
artist, title = artist.strip(), title.strip()
|
||||
if not title:
|
||||
_speak("Трек без названия.")
|
||||
return
|
||||
if artist:
|
||||
_speak(f"Сейчас играет: {artist} — {title}.")
|
||||
else:
|
||||
_speak(f"Сейчас играет: {title}.")
|
||||
|
||||
|
||||
# ── World Clock ─────────────────────────────────────────────────────────────
|
||||
|
||||
_TZ_MAP = {
|
||||
'москва': 'Europe/Moscow',
|
||||
'петербург': 'Europe/Moscow', 'спб': 'Europe/Moscow',
|
||||
'новосибирск': 'Asia/Novosibirsk',
|
||||
'екатеринбург': 'Asia/Yekaterinburg',
|
||||
'владивосток': 'Asia/Vladivostok',
|
||||
'омск': 'Asia/Omsk',
|
||||
'красноярск': 'Asia/Krasnoyarsk',
|
||||
'лондон': 'Europe/London',
|
||||
'нью-йорк': 'America/New_York', 'нью йорк': 'America/New_York',
|
||||
'токио': 'Asia/Tokyo',
|
||||
'пекин': 'Asia/Shanghai',
|
||||
'сидней': 'Australia/Sydney',
|
||||
'дубай': 'Asia/Dubai',
|
||||
'париж': 'Europe/Paris',
|
||||
'берлин': 'Europe/Berlin',
|
||||
'киев': 'Europe/Kyiv',
|
||||
'минск': 'Europe/Minsk',
|
||||
'алматы': 'Asia/Almaty',
|
||||
'ташкент': 'Asia/Tashkent',
|
||||
'сеул': 'Asia/Seoul',
|
||||
}
|
||||
|
||||
|
||||
def do_world_clock(action, voice):
|
||||
import urllib.request
|
||||
low = (voice or '').lower()
|
||||
city = low
|
||||
for trig in ('сколько времени в', 'который час в', 'сколько часов в', 'время в'):
|
||||
idx = city.find(trig)
|
||||
if idx >= 0:
|
||||
city = city[idx + len(trig):].strip(' ,.:')
|
||||
break
|
||||
if not city:
|
||||
_speak("В каком городе?")
|
||||
return
|
||||
|
||||
tz = None
|
||||
for stem, zone in _TZ_MAP.items():
|
||||
if stem in city:
|
||||
tz = zone
|
||||
break
|
||||
if not tz:
|
||||
_speak("Не знаю такого города.")
|
||||
return
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"https://worldtimeapi.org/api/timezone/{tz}",
|
||||
timeout=8) as r:
|
||||
import json as _json
|
||||
data = _json.loads(r.read().decode())
|
||||
except Exception as exc:
|
||||
print(f"[ext] world_clock: {exc}")
|
||||
_speak("Не получилось узнать время.")
|
||||
return
|
||||
|
||||
dt = data.get('datetime', '')
|
||||
m = re.search(r'T(\d{2}):(\d{2})', dt)
|
||||
if not m:
|
||||
_speak("Не понял ответ сервиса.")
|
||||
return
|
||||
_speak(f"В {city} сейчас {m.group(1)}:{m.group(2)}.")
|
||||
|
||||
|
||||
# ── Daily quote ─────────────────────────────────────────────────────────────
|
||||
|
||||
def do_daily_quote(action, voice):
|
||||
import urllib.request
|
||||
import json as _json
|
||||
try:
|
||||
with urllib.request.urlopen("https://zenquotes.io/api/random", timeout=8) as r:
|
||||
data = _json.loads(r.read().decode())
|
||||
except Exception as exc:
|
||||
print(f"[ext] daily_quote: {exc}")
|
||||
_speak("Не получилось получить цитату.")
|
||||
return
|
||||
|
||||
if not data or not isinstance(data, list):
|
||||
_speak("Пустой ответ.")
|
||||
return
|
||||
q = data[0]
|
||||
quote_en = q.get('q', '')
|
||||
author = q.get('a', 'Аноним')
|
||||
if not quote_en:
|
||||
_speak("Пустая цитата.")
|
||||
return
|
||||
|
||||
client = llm_backend.current_client()
|
||||
text = quote_en
|
||||
if client:
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=llm_backend.current_model(),
|
||||
messages=[
|
||||
{'role': 'system', 'content': 'Переведи английскую цитату на русский. Только перевод.'},
|
||||
{'role': 'user', 'content': quote_en},
|
||||
],
|
||||
max_tokens=200, temperature=0.2, timeout=20,
|
||||
)
|
||||
translated = resp.choices[0].message.content.strip()
|
||||
if translated:
|
||||
text = translated
|
||||
except Exception as exc:
|
||||
print(f"[ext] daily_quote translate: {exc}")
|
||||
|
||||
_speak(f"{text} — {author}.")
|
||||
|
||||
|
||||
# ── codebase Q&A (proxies to dev_handlers) ──────────────────────────────────
|
||||
|
||||
def do_codebase_set(action, voice):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue