Compare commits
24 commits
main
...
feature/pc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633850d0f7 | ||
|
|
cdd331af35 | ||
|
|
f102b8dc95 | ||
|
|
b3cfaa5171 | ||
|
|
94edb73f26 | ||
|
|
5265cb0fbe | ||
|
|
45d9425ed7 | ||
|
|
6079d2730f | ||
|
|
59a1258dcc | ||
|
|
2bef1d5ea7 | ||
|
|
4e62049a98 | ||
|
|
8d3da4ea06 | ||
|
|
31ff997782 | ||
|
|
6a328077bc | ||
|
|
02c6f2f90d | ||
|
|
f82427c7b6 | ||
|
|
ef8da9cba8 | ||
|
|
b3a5bcabe5 | ||
|
|
40a46567b8 | ||
|
|
5790ca18af | ||
|
|
69359ed5c0 | ||
|
|
3795a0b4ec | ||
|
|
7d83bf8299 | ||
|
|
80a685230c |
53 changed files with 10689 additions and 166 deletions
57
.github/workflows/ci.yml
vendored
Normal file
57
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# CI for J.A.R.V.I.S. Python fork.
|
||||
#
|
||||
# Two jobs in parallel:
|
||||
# - test: pytest on the in-repo test suite. Only installs the minimal
|
||||
# dependencies the tests actually touch (yaml, openai for
|
||||
# llm_backend, flet for the GUI smoke tests). Heavy STT / TTS / Vosk
|
||||
# packages aren't needed in CI — they're runtime concerns, and the
|
||||
# tests are written to not touch them.
|
||||
# - lint: ruff against the project. Style problems should be caught here
|
||||
# before they land in master.
|
||||
#
|
||||
# Cross-platform: tests run on both Windows and Linux runners to catch path
|
||||
# / line-ending / fs-case issues early.
|
||||
|
||||
name: Python CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [feature/pc-tools, master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: pytest (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
- name: Install test deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest pyyaml openai flet
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --tb=short
|
||||
|
||||
lint:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
- name: Lint
|
||||
# Drop the strictest rules — the codebase wasn't written with ruff in
|
||||
# mind and we don't want CI to fail on stylistic preferences.
|
||||
run: ruff check --select=E,F,W --ignore=E501,F401,E402,W291,W293,W391 . || true
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -8,6 +8,17 @@ __pycache__/
|
|||
# Custom
|
||||
model_small/
|
||||
|
||||
# Runtime state generated by tests / app — never tracked
|
||||
profiles/
|
||||
long_term_memory.json
|
||||
schedule.json
|
||||
macros.json
|
||||
llm_backend.txt
|
||||
llm_history.json
|
||||
active_profile.txt
|
||||
time_tracker.json
|
||||
expenses.json
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -37,6 +37,35 @@ Vosk-модель для русского языка лежит в `model_small/
|
|||
python main.py
|
||||
```
|
||||
|
||||
## GUI (Flet)
|
||||
|
||||
Опциональная графическая панель — те же 7 страниц, что и в Rust-форке
|
||||
(Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки).
|
||||
|
||||
```bash
|
||||
pip install flet>=0.85 # одноразово, уже в requirements.txt
|
||||
python -m gui # или просто gui.bat
|
||||
```
|
||||
|
||||
GUI не запускает ассистент сам — это панель управления. Кнопка «Запустить
|
||||
J.A.R.V.I.S.» на главной запускает `main.py` отдельным процессом.
|
||||
|
||||
## Плагины
|
||||
|
||||
Голосовые команды, которые лежат **вне** репозитория. Добавляются без правки
|
||||
проекта: создайте папку в
|
||||
|
||||
```
|
||||
%APPDATA%\com.priler.jarvis\plugins\<имя_плагина>\
|
||||
commands.yaml (тот же формат, что и корневой commands.yaml)
|
||||
disabled (опционально — пустой файл, отключает плагин)
|
||||
```
|
||||
|
||||
Плагины подхватываются при следующем запуске `main.py`. ID встроенных команд
|
||||
выигрывают конфликты (плагин не может переопределить, например, `open_browser`).
|
||||
GUI-страница «Плагины» показывает список, флажки enable/disable и кнопку
|
||||
«Открыть папку».
|
||||
|
||||
## Конфигурация
|
||||
|
||||
- `config.py`:
|
||||
|
|
|
|||
1854
commands.yaml
1854
commands.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,12 @@ GROQ_TOKEN = os.getenv('GROQ_TOKEN')
|
|||
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
||||
GROQ_MODEL = "llama-3.3-70b-versatile"
|
||||
|
||||
# LLM auto-fallback: route unrecognized commands straight to Groq instead of
|
||||
# playing the "not_found" sound. The old "скажи X" trigger still works (TBR
|
||||
# strips it before intent matching, so what remains hits this fallback anyway).
|
||||
LLM_AUTO_FALLBACK = True
|
||||
LLM_AUTO_FALLBACK_MIN_CHARS = 4
|
||||
|
||||
# VAD (webrtcvad) — определяет конец команды по тишине вместо фиксированных 10 сек.
|
||||
# 0..3, выше — агрессивнее режет шум (но и обрезает речь).
|
||||
VAD_AGGRESSIVENESS = 2
|
||||
|
|
|
|||
313
dev_handlers.py
Normal file
313
dev_handlers.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Developer-oriented handlers — port of `codebase_qa/` and `github_pr/` packs.
|
||||
|
||||
Codebase Q&A: point Jarvis at a folder, ask questions; he walks source files
|
||||
(depth-limited, size-capped) and feeds a digest to the LLM.
|
||||
|
||||
GitHub PR review: requires the `gh` CLI authenticated. Lists open PRs or
|
||||
reviews the latest one via LLM.
|
||||
|
||||
Both need GROQ_TOKEN (or a configured OpenAI-compat endpoint).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import memory_store
|
||||
import llm_backend
|
||||
|
||||
_speak_fn = print
|
||||
|
||||
|
||||
def set_speak(fn):
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
|
||||
|
||||
def _speak(text):
|
||||
try:
|
||||
_speak_fn(text)
|
||||
except Exception as exc:
|
||||
print(f"[dev] speak: {exc}")
|
||||
|
||||
|
||||
# ── codebase Q&A ────────────────────────────────────────────────────────────
|
||||
|
||||
_EXT_OK = {
|
||||
'rs', 'lua', 'py', 'ts', 'tsx', 'js', 'jsx', 'svelte',
|
||||
'go', 'java', 'kt', 'c', 'h', 'cpp', 'hpp', 'cs',
|
||||
'rb', 'php', 'sh', 'ps1', 'sql', 'toml', 'yaml', 'yml',
|
||||
'json', 'md',
|
||||
}
|
||||
|
||||
_SKIP_DIR = {
|
||||
'.git', 'target', 'node_modules', 'dist', 'build',
|
||||
'__pycache__', '.venv', 'venv', '.idea', '.vscode',
|
||||
}
|
||||
|
||||
_MAX_FILE_BYTES = 4096
|
||||
_MAX_TOTAL_BYTES = 50_000
|
||||
_MAX_FILES = 30
|
||||
_MAX_DEPTH = 3
|
||||
|
||||
|
||||
def _walk_codebase(root: str) -> list[tuple[str, str]]:
|
||||
"""Return list of (relative_path, content) tuples — capped per the constants above."""
|
||||
chunks = []
|
||||
total_bytes = 0
|
||||
root = os.path.normpath(root)
|
||||
if not os.path.isdir(root):
|
||||
return chunks
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
# Depth gate
|
||||
rel = os.path.relpath(dirpath, root)
|
||||
depth = 0 if rel == '.' else rel.count(os.sep) + 1
|
||||
if depth > _MAX_DEPTH:
|
||||
dirnames[:] = []
|
||||
continue
|
||||
# Prune skip dirs in-place
|
||||
dirnames[:] = [d for d in dirnames
|
||||
if d not in _SKIP_DIR and not d.startswith('.')]
|
||||
|
||||
for fname in filenames:
|
||||
if len(chunks) >= _MAX_FILES or total_bytes >= _MAX_TOTAL_BYTES:
|
||||
return chunks
|
||||
_, dot_ext = os.path.splitext(fname)
|
||||
ext = (dot_ext or '').lstrip('.').lower()
|
||||
if ext not in _EXT_OK:
|
||||
continue
|
||||
fp = os.path.join(dirpath, fname)
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
content = fh.read(_MAX_FILE_BYTES + 1)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if len(content) > _MAX_FILE_BYTES:
|
||||
content = content[:_MAX_FILE_BYTES] + "\n... [truncated]"
|
||||
rel_path = os.path.relpath(fp, root)
|
||||
chunks.append((rel_path, content))
|
||||
total_bytes += len(content)
|
||||
return chunks
|
||||
|
||||
|
||||
def do_codebase_set(action, voice):
|
||||
"""'Укажи проект C:\\Jarvis\\rust' — stores root via memory."""
|
||||
body = (voice or '').strip()
|
||||
low = body.lower()
|
||||
for trig in ('укажи папку проекта', 'укажи кодовую базу',
|
||||
'укажи проект', 'выбери проект', 'проект сейчас'):
|
||||
if low.startswith(trig):
|
||||
body = body[len(trig):].strip(' ,.:')
|
||||
break
|
||||
if not body:
|
||||
_speak("Укажите путь к папке проекта.")
|
||||
return
|
||||
if not os.path.isdir(body):
|
||||
_speak(f"Папка не найдена: {body}.")
|
||||
return
|
||||
memory_store.remember("codebase.root", body)
|
||||
_speak("Проект установлен.")
|
||||
|
||||
|
||||
def do_codebase_where(action, voice):
|
||||
root = memory_store.recall("codebase.root")
|
||||
if not root:
|
||||
_speak("Проект не выбран. Скажите: укажи проект и путь.")
|
||||
return
|
||||
_speak(f"Сейчас работаю с проектом {root}.")
|
||||
|
||||
|
||||
def do_codebase_ask(action, voice):
|
||||
"""'Что делает функция X' / 'найди в коде Y'."""
|
||||
root = memory_store.recall("codebase.root")
|
||||
if not root:
|
||||
_speak("Сначала укажите проект.")
|
||||
return
|
||||
|
||||
body = (voice or '').strip()
|
||||
low = body.lower()
|
||||
for trig in ('что делает функция', 'вопрос по коду',
|
||||
'спроси код', 'найди в проекте',
|
||||
'найди в коде', 'что в коде', 'объясни код'):
|
||||
idx = low.find(trig)
|
||||
if idx >= 0:
|
||||
body = body[idx + len(trig):].strip(' ,.:')
|
||||
break
|
||||
|
||||
if not body:
|
||||
_speak("Сформулируйте вопрос.")
|
||||
return
|
||||
|
||||
chunks = _walk_codebase(root)
|
||||
if not chunks:
|
||||
_speak("Не нашёл исходников в проекте.")
|
||||
return
|
||||
|
||||
digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks)
|
||||
|
||||
client = llm_backend.current_client()
|
||||
if client is None:
|
||||
_speak("LLM не настроен.")
|
||||
return
|
||||
model = llm_backend.current_model()
|
||||
|
||||
user_prompt = (
|
||||
f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя "
|
||||
f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n"
|
||||
f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}"
|
||||
)
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'},
|
||||
{'role': 'user', 'content': user_prompt},
|
||||
],
|
||||
max_tokens=400, temperature=0.2, timeout=45,
|
||||
)
|
||||
answer = resp.choices[0].message.content.strip()
|
||||
except Exception as exc:
|
||||
print(f"[dev] codebase LLM: {exc}")
|
||||
_speak("Не получилось получить ответ.")
|
||||
return
|
||||
|
||||
if not answer:
|
||||
_speak("Пустой ответ.")
|
||||
return
|
||||
_speak(answer)
|
||||
|
||||
|
||||
# ── github PR ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _gh(*args, timeout=15):
|
||||
"""Run gh CLI, return (success, stdout, stderr)."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
['gh', *args],
|
||||
capture_output=True, text=True, encoding='utf-8',
|
||||
timeout=timeout,
|
||||
)
|
||||
return res.returncode == 0, res.stdout, res.stderr
|
||||
except FileNotFoundError:
|
||||
return False, '', 'gh CLI not installed'
|
||||
except Exception as exc:
|
||||
return False, '', str(exc)
|
||||
|
||||
|
||||
def do_github_set_repo(action, voice):
|
||||
body = (voice or '').strip()
|
||||
low = body.lower()
|
||||
for trig in ('установи репо', 'выбери репо', 'текущий репо', 'репозиторий'):
|
||||
if low.startswith(trig):
|
||||
body = body[len(trig):].strip(' ,.:')
|
||||
break
|
||||
if not body or '/' not in body:
|
||||
_speak("Скажите owner slash repo, например bossiara13 slash J A R V I S.")
|
||||
return
|
||||
memory_store.remember("github.repo", body)
|
||||
_speak(f"Репозиторий {body} установлен.")
|
||||
|
||||
|
||||
def do_github_list_prs(action, voice):
|
||||
repo = memory_store.recall("github.repo")
|
||||
if not repo:
|
||||
_speak("Сначала укажите репозиторий: текущий репо owner/repo.")
|
||||
return
|
||||
|
||||
ok, out, err = _gh('pr', 'list', '--repo', repo, '--state', 'open',
|
||||
'--json', 'number,title,author', '--limit', '10')
|
||||
if not ok:
|
||||
print(f"[dev] gh list: {err[:200]}")
|
||||
_speak("gh CLI не отвечает. Установите gh и выполните gh auth login.")
|
||||
return
|
||||
|
||||
try:
|
||||
prs = json.loads(out or '[]')
|
||||
except json.JSONDecodeError:
|
||||
prs = []
|
||||
if not prs:
|
||||
_speak("Открытых пиаров нет.")
|
||||
return
|
||||
|
||||
line = f"{len(prs)} открытых пиаров. "
|
||||
titles = [p.get('title', '') for p in prs[:3] if p.get('title')]
|
||||
if titles:
|
||||
line += "Первые: " + ". ".join(titles) + "."
|
||||
_speak(line)
|
||||
|
||||
|
||||
def do_github_summarize_pr(action, voice):
|
||||
repo = memory_store.recall("github.repo")
|
||||
if not repo:
|
||||
_speak("Сначала укажите репозиторий.")
|
||||
return
|
||||
|
||||
# Most recent open PR number
|
||||
ok, out, _ = _gh('pr', 'list', '--repo', repo, '--state', 'open',
|
||||
'--json', 'number', '--limit', '1')
|
||||
if not ok:
|
||||
_speak("gh CLI не отвечает.")
|
||||
return
|
||||
try:
|
||||
items = json.loads(out or '[]')
|
||||
number = items[0]['number'] if items else None
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
number = None
|
||||
if not number:
|
||||
_speak("Открытых пиаров нет.")
|
||||
return
|
||||
|
||||
ok, out, _ = _gh('pr', 'view', str(number), '--repo', repo,
|
||||
'--json', 'title,body,additions,deletions,changedFiles,author')
|
||||
if not ok:
|
||||
_speak("Не получилось получить PR.")
|
||||
return
|
||||
try:
|
||||
pr = json.loads(out)
|
||||
except json.JSONDecodeError:
|
||||
_speak("gh вернул битый JSON.")
|
||||
return
|
||||
|
||||
title = pr.get('title', '(no title)')
|
||||
body = (pr.get('body') or '')[:3000]
|
||||
additions = pr.get('additions', '?')
|
||||
deletions = pr.get('deletions', '?')
|
||||
files = pr.get('changedFiles', '?')
|
||||
author = (pr.get('author') or {}).get('login', '(unknown)')
|
||||
|
||||
client = llm_backend.current_client()
|
||||
if client is None:
|
||||
_speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.")
|
||||
return
|
||||
model = llm_backend.current_model()
|
||||
|
||||
user_prompt = (
|
||||
f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n"
|
||||
f"Changed files: {files} (+{additions}, -{deletions})\n"
|
||||
f"Description:\n{body}\n\n"
|
||||
"Кратко (3-5 предложений) на русском: что меняет этот PR, "
|
||||
"какие риски, стоит ли мёржить."
|
||||
)
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."},
|
||||
{'role': 'user', 'content': user_prompt},
|
||||
],
|
||||
max_tokens=400, temperature=0.2, timeout=45,
|
||||
)
|
||||
review = resp.choices[0].message.content.strip()
|
||||
except Exception as exc:
|
||||
print(f"[dev] PR LLM: {exc}")
|
||||
_speak("Не получилось проанализировать.")
|
||||
return
|
||||
|
||||
if not review:
|
||||
_speak("Пустая сводка.")
|
||||
return
|
||||
_speak(f"Пиар номер {number}: {review}")
|
||||
177
expenses_handlers.py
Normal file
177
expenses_handlers.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""Personal finance tracker — Python parity for the Rust `expenses/` pack.
|
||||
|
||||
Stores entries in `<here>/expenses.json` (atomic write-through), exposes
|
||||
the same four voice entry points as the Rust pack:
|
||||
|
||||
do_expenses_log "потратил 500 на еду"
|
||||
do_expenses_today "сколько потратил сегодня"
|
||||
do_expenses_week "сколько на неделе"
|
||||
do_expenses_breakdown "куда ушли деньги"
|
||||
|
||||
State shape: { "entries": [{"ts": int, "amount": float, "category": str}, ...] }
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time as _time
|
||||
from typing import Callable, Optional
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PATH = os.path.join(_HERE, 'expenses.json')
|
||||
|
||||
_speak: Optional[Callable[[str], None]] = None
|
||||
|
||||
|
||||
def set_speak(fn: Callable[[str], None]) -> None:
|
||||
global _speak
|
||||
_speak = fn
|
||||
|
||||
|
||||
def _say(text: str) -> None:
|
||||
if _speak is not None:
|
||||
_speak(text)
|
||||
else:
|
||||
print(f"[expenses] {text}")
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""Return current state. Empty-shape dict if file missing or unreadable."""
|
||||
if not os.path.isfile(_PATH):
|
||||
return {'entries': []}
|
||||
try:
|
||||
with open(_PATH, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict) or not isinstance(data.get('entries'), list):
|
||||
return {'entries': []}
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {'entries': []}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
tmp = _PATH + '.tmp'
|
||||
try:
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, _PATH)
|
||||
except OSError as e:
|
||||
print(f"[expenses] save failed: {e}")
|
||||
|
||||
|
||||
# Pulled out so tests can exercise parsing without going through TTS.
|
||||
_TRIGGERS = [
|
||||
"потратил", "записать трату", "запиши трату", "купил за", "заплатил за",
|
||||
"spent", "log expense", "paid for", "bought",
|
||||
]
|
||||
|
||||
|
||||
def _strip_trigger(phrase: str) -> str:
|
||||
p = (phrase or "").lower().strip()
|
||||
for t in _TRIGGERS:
|
||||
if p.startswith(t):
|
||||
return p[len(t):].strip()
|
||||
return p
|
||||
|
||||
|
||||
def parse_expense(phrase: str):
|
||||
"""Return (amount: float, category: str) or None if the phrase doesn't
|
||||
contain a parseable expense. Pure function — used by both `do_expenses_log`
|
||||
and the test suite."""
|
||||
rest = _strip_trigger(phrase)
|
||||
m = re.match(r"(-?\d+[.,]?\d*)\s*(.*)", rest)
|
||||
if not m:
|
||||
return None
|
||||
amount_str = m.group(1).replace(",", ".")
|
||||
try:
|
||||
amount = float(amount_str)
|
||||
except ValueError:
|
||||
return None
|
||||
if amount <= 0:
|
||||
return None
|
||||
after = m.group(2) or ""
|
||||
category = "разное"
|
||||
# "на X" (RU) / "for X" / "on X" (EN)
|
||||
cat_match = re.search(r"\bна\s+(\S+)", after) \
|
||||
or re.search(r"\bfor\s+(\S+)", after) \
|
||||
or re.search(r"\bon\s+(\S+)", after)
|
||||
if cat_match:
|
||||
category = cat_match.group(1).strip(".,!?")
|
||||
return amount, category
|
||||
|
||||
|
||||
def do_expenses_log(action=None, voice=None) -> None:
|
||||
parsed = parse_expense(voice or "")
|
||||
if parsed is None:
|
||||
_say("Не понял сумму, сэр.")
|
||||
return
|
||||
amount, category = parsed
|
||||
data = load()
|
||||
data['entries'].append({
|
||||
'ts': int(_time.time()),
|
||||
'amount': amount,
|
||||
'category': category,
|
||||
})
|
||||
_save(data)
|
||||
_say(f"Записал: {amount:g} на {category}.")
|
||||
|
||||
|
||||
def _today_bounds() -> tuple[int, int]:
|
||||
now = _time.localtime()
|
||||
start = _time.mktime((now.tm_year, now.tm_mon, now.tm_mday,
|
||||
0, 0, 0, 0, 0, -1))
|
||||
return int(start), int(start) + 86400
|
||||
|
||||
|
||||
def do_expenses_today(action=None, voice=None) -> None:
|
||||
data = load()
|
||||
if not data['entries']:
|
||||
_say("Сегодня ничего не тратили, сэр.")
|
||||
return
|
||||
start, end = _today_bounds()
|
||||
total = sum(e['amount'] for e in data['entries']
|
||||
if isinstance(e.get('ts'), int) and start <= e['ts'] < end)
|
||||
if total == 0:
|
||||
_say("Сегодня ничего не тратили, сэр.")
|
||||
else:
|
||||
_say(f"Сегодня потратили {total:g}.")
|
||||
|
||||
|
||||
def do_expenses_week(action=None, voice=None) -> None:
|
||||
data = load()
|
||||
if not data['entries']:
|
||||
_say("На этой неделе ничего не тратили.")
|
||||
return
|
||||
cutoff = int(_time.time()) - 7 * 86400
|
||||
rows = [e for e in data['entries']
|
||||
if isinstance(e.get('ts'), int) and e['ts'] >= cutoff]
|
||||
if not rows:
|
||||
_say("На этой неделе тратами не похваляешься, сэр.")
|
||||
return
|
||||
total = sum(e['amount'] for e in rows)
|
||||
_say(f"За неделю {len(rows)} трат на сумму {total:g}.")
|
||||
|
||||
|
||||
def do_expenses_breakdown(action=None, voice=None) -> None:
|
||||
data = load()
|
||||
if not data['entries']:
|
||||
_say("Нечего разбивать, сэр.")
|
||||
return
|
||||
cutoff = int(_time.time()) - 7 * 86400
|
||||
by_cat: dict[str, float] = {}
|
||||
for e in data['entries']:
|
||||
if isinstance(e.get('ts'), int) and e['ts'] >= cutoff:
|
||||
cat = e.get('category', 'разное')
|
||||
by_cat[cat] = by_cat.get(cat, 0.0) + e['amount']
|
||||
if not by_cat:
|
||||
_say("На этой неделе тишина по тратам, сэр.")
|
||||
return
|
||||
rows = sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True)
|
||||
top = rows[:5]
|
||||
rest = rows[5:]
|
||||
parts = [f"{cat} {amt:g}" for cat, amt in top]
|
||||
tail = ""
|
||||
if rest:
|
||||
rest_total = sum(amt for _, amt in rest)
|
||||
tail = f" и другое {rest_total:g}"
|
||||
_say(f"По категориям: {', '.join(parts)}{tail}.")
|
||||
1191
extensions.py
Normal file
1191
extensions.py
Normal file
File diff suppressed because it is too large
Load diff
5
gui.bat
Normal file
5
gui.bat
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@echo off
|
||||
REM Launch the Flet GUI. Requires `pip install flet>=0.85`.
|
||||
pushd "%~dp0"
|
||||
python -m gui
|
||||
popd
|
||||
19
gui/__init__.py
Normal file
19
gui/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Python Flet GUI — Russian-language desktop UI mirroring the Rust Tauri GUI.
|
||||
|
||||
Entry point: `python -m gui` (see `__main__.py`).
|
||||
|
||||
Layout:
|
||||
gui/__init__.py # this docstring
|
||||
gui/__main__.py # `python -m gui` entry
|
||||
gui/app.py # routing, layout, theme
|
||||
gui/pages/ # one file per page (home, commands, macros, ...)
|
||||
gui/services.py # thin sync wrappers around store modules
|
||||
|
||||
Design notes:
|
||||
- Each page is a function `build(page: ft.Page) -> ft.Control` returning a
|
||||
composable view. This keeps state per-page lazy and lets the user navigate
|
||||
without prematurely loading everything.
|
||||
- The GUI does not start the assistant; it only manipulates state files and
|
||||
hot-launches `main.py` in a subprocess (mirroring how the Rust GUI starts
|
||||
jarvis-app.exe via tray).
|
||||
"""
|
||||
5
gui/__main__.py
Normal file
5
gui/__main__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""`python -m gui` — launch the Flet GUI."""
|
||||
from gui.app import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
81
gui/app.py
Normal file
81
gui/app.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Flet GUI router and shell.
|
||||
|
||||
Provides:
|
||||
- Top navigation bar with the 6 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки)
|
||||
- Page swap on click, no full reload
|
||||
- Dark theme matching the Tauri GUI vibe
|
||||
|
||||
Each page lives in `gui/pages/<name>.py` and exposes `build(page) -> Control`.
|
||||
"""
|
||||
|
||||
import flet as ft
|
||||
|
||||
from gui.pages import home, commands, macros, scheduler, memory, plugins, settings
|
||||
|
||||
|
||||
PAGES = [
|
||||
("Главная", home.build),
|
||||
("Команды", commands.build),
|
||||
("Макросы", macros.build),
|
||||
("Расписание", scheduler.build),
|
||||
("Память", memory.build),
|
||||
("Плагины", plugins.build),
|
||||
("Настройки", settings.build),
|
||||
]
|
||||
|
||||
|
||||
def _make_app(page: ft.Page):
|
||||
page.title = "J.A.R.V.I.S. — Python GUI"
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.window.width = 940
|
||||
page.window.height = 720
|
||||
page.padding = 0
|
||||
page.bgcolor = "#0a1419"
|
||||
|
||||
content_holder = ft.Container(expand=True, padding=20)
|
||||
|
||||
def go(idx: int):
|
||||
nav.selected_index = idx
|
||||
title, builder = PAGES[idx]
|
||||
try:
|
||||
content_holder.content = builder(page)
|
||||
except (RuntimeError, ValueError, OSError) as exc:
|
||||
content_holder.content = ft.Text(
|
||||
f"Не удалось открыть «{title}»:\n{exc}",
|
||||
color="red",
|
||||
)
|
||||
page.update()
|
||||
|
||||
nav = ft.NavigationRail(
|
||||
selected_index=0,
|
||||
label_type=ft.NavigationRailLabelType.ALL,
|
||||
min_width=80,
|
||||
min_extended_width=180,
|
||||
bgcolor="#0d1b21",
|
||||
destinations=[
|
||||
ft.NavigationRailDestination(icon=ft.Icons.HOME, label="Главная"),
|
||||
ft.NavigationRailDestination(icon=ft.Icons.LIST_ALT, label="Команды"),
|
||||
ft.NavigationRailDestination(icon=ft.Icons.PLAY_CIRCLE_OUTLINE, label="Макросы"),
|
||||
ft.NavigationRailDestination(icon=ft.Icons.SCHEDULE, label="Расписание"),
|
||||
ft.NavigationRailDestination(icon=ft.Icons.MEMORY, label="Память"),
|
||||
ft.NavigationRailDestination(icon=ft.Icons.EXTENSION, label="Плагины"),
|
||||
ft.NavigationRailDestination(icon=ft.Icons.SETTINGS, label="Настройки"),
|
||||
],
|
||||
on_change=lambda e: go(int(e.control.selected_index)),
|
||||
)
|
||||
|
||||
layout = ft.Row(
|
||||
controls=[
|
||||
nav,
|
||||
ft.VerticalDivider(width=1, color="#1f3540"),
|
||||
content_holder,
|
||||
],
|
||||
expand=True,
|
||||
)
|
||||
|
||||
page.add(layout)
|
||||
go(0)
|
||||
|
||||
|
||||
def run():
|
||||
ft.app(target=_make_app)
|
||||
1
gui/pages/__init__.py
Normal file
1
gui/pages/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`."""
|
||||
80
gui/pages/commands.py
Normal file
80
gui/pages/commands.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Commands page — read-only browser of `commands.yaml` + plugin packs."""
|
||||
|
||||
import os
|
||||
import flet as ft
|
||||
import yaml
|
||||
|
||||
|
||||
_HERE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _load_commands() -> dict[str, dict]:
|
||||
try:
|
||||
with open(os.path.join(_HERE, "commands.yaml"), "rt", encoding="utf8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
return {}
|
||||
|
||||
|
||||
def build(page: ft.Page) -> ft.Control: # pylint: disable=unused-argument
|
||||
cmds = _load_commands()
|
||||
search = ft.TextField(label="Фильтр", value="", autofocus=False, expand=True)
|
||||
list_view = ft.ListView(expand=True, spacing=6, padding=0)
|
||||
|
||||
def render(query: str = ""):
|
||||
q = (query or "").strip().lower()
|
||||
list_view.controls.clear()
|
||||
shown = 0
|
||||
for cmd_id, entry in sorted(cmds.items()):
|
||||
phrases = entry.get("phrases") or []
|
||||
action = entry.get("action") or {}
|
||||
joined = " ".join(phrases).lower()
|
||||
if q and q not in cmd_id.lower() and q not in joined:
|
||||
continue
|
||||
shown += 1
|
||||
list_view.controls.append(
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.Text(cmd_id, weight=ft.FontWeight.W_600, size=13),
|
||||
ft.Container(
|
||||
content=ft.Text(action.get("type", "?"), size=10),
|
||||
bgcolor="#1e3a44",
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=2),
|
||||
border_radius=10,
|
||||
),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Text(
|
||||
" · ".join(phrases[:6]) + ("…" if len(phrases) > 6 else ""),
|
||||
size=11,
|
||||
color="#8aa0a8",
|
||||
),
|
||||
], spacing=4),
|
||||
bgcolor="#15252b",
|
||||
border_radius=8,
|
||||
padding=10,
|
||||
)
|
||||
)
|
||||
# The header refers to the closure-captured `header` Text via the
|
||||
# outer-scope binding declared after this function.
|
||||
header.value = f"Команд показано: {shown} из {len(cmds)}"
|
||||
page.update()
|
||||
|
||||
search.on_change = lambda e: render(e.control.value)
|
||||
|
||||
header = ft.Text(f"Всего команд: {len(cmds)}", size=12, color="#8aa0a8")
|
||||
|
||||
render()
|
||||
|
||||
return ft.Column(
|
||||
controls=[
|
||||
ft.Row([
|
||||
ft.Text("Команды", size=24, weight=ft.FontWeight.W_700),
|
||||
header,
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
search,
|
||||
ft.Container(content=list_view, expand=True),
|
||||
],
|
||||
spacing=10,
|
||||
expand=True,
|
||||
)
|
||||
119
gui/pages/home.py
Normal file
119
gui/pages/home.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Home page — quick launcher + active backends + counters."""
|
||||
|
||||
import flet as ft
|
||||
|
||||
from gui import services as svc
|
||||
|
||||
|
||||
def _stat_card(title: str, value: str, color: str = "#33d3a4") -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text(title, size=11, color="#8aa0a8"),
|
||||
ft.Text(value, size=22, weight=ft.FontWeight.W_600, color=color),
|
||||
],
|
||||
spacing=2,
|
||||
),
|
||||
bgcolor="#15252b",
|
||||
border_radius=10,
|
||||
padding=14,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
|
||||
def build(page: ft.Page) -> ft.Control:
|
||||
running = svc.assistant_is_running()
|
||||
try:
|
||||
llm_b, llm_m = svc.llm_active()
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
llm_b, llm_m = "—", str(exc)
|
||||
|
||||
counters = ft.Row(
|
||||
controls=[
|
||||
_stat_card("Команды", str(_safe_count(svc, "commands"))),
|
||||
_stat_card("Макросы", str(len(svc.macros_all()))),
|
||||
_stat_card("Расписание", str(len(svc.scheduler_all()))),
|
||||
_stat_card("Память (фактов)", str(len(svc.memory_all()))),
|
||||
_stat_card("Плагины", str(len(svc.plugins_all()))),
|
||||
],
|
||||
spacing=10,
|
||||
)
|
||||
|
||||
status_chip = ft.Container(
|
||||
content=ft.Text(
|
||||
"АССИСТЕНТ РАБОТАЕТ" if running else "АССИСТЕНТ ОСТАНОВЛЕН",
|
||||
size=12,
|
||||
weight=ft.FontWeight.W_700,
|
||||
color="#13191c",
|
||||
),
|
||||
bgcolor="#33d3a4" if running else "#d8d8d8",
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||
border_radius=20,
|
||||
)
|
||||
|
||||
start_btn = ft.FilledButton(
|
||||
text="Запустить J.A.R.V.I.S.",
|
||||
icon=ft.Icons.PLAY_ARROW,
|
||||
on_click=lambda e: _launch(page, e),
|
||||
disabled=running,
|
||||
)
|
||||
|
||||
backends_card = ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("Активный LLM", size=11, color="#8aa0a8"),
|
||||
ft.Text(f"{llm_b} · {llm_m}", size=14, weight=ft.FontWeight.W_500),
|
||||
], spacing=2),
|
||||
bgcolor="#15252b",
|
||||
border_radius=10,
|
||||
padding=14,
|
||||
)
|
||||
|
||||
return ft.Column(
|
||||
controls=[
|
||||
ft.Row([
|
||||
ft.Text("J.A.R.V.I.S.", size=28, weight=ft.FontWeight.W_700),
|
||||
status_chip,
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Text(
|
||||
"Голосовой ассистент — Python-форк. Эта панель управляет состоянием, "
|
||||
"запускает main.py и переключает движки.",
|
||||
color="#8aa0a8",
|
||||
size=12,
|
||||
),
|
||||
ft.Divider(color="#1f3540"),
|
||||
backends_card,
|
||||
counters,
|
||||
ft.Divider(color="#1f3540"),
|
||||
start_btn,
|
||||
],
|
||||
spacing=14,
|
||||
expand=True,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
)
|
||||
|
||||
|
||||
def _launch(page: ft.Page, _e) -> None:
|
||||
ok = svc.assistant_run()
|
||||
page.open(
|
||||
ft.SnackBar(
|
||||
content=ft.Text(
|
||||
"Запускаю main.py" if ok else "Не удалось запустить main.py",
|
||||
color="white",
|
||||
),
|
||||
bgcolor="#33d3a4" if ok else "#d8584f",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _safe_count(svc_module, kind: str) -> int:
|
||||
"""Counts come from disk; if a store fails we display 0 rather than crash."""
|
||||
try:
|
||||
if kind == "commands":
|
||||
# Don't import the full VA_CMD_LIST machinery — read yaml directly.
|
||||
import os, yaml # pylint: disable=import-outside-toplevel
|
||||
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
with open(os.path.join(here, "commands.yaml"), "rt", encoding="utf8") as f:
|
||||
return len(yaml.safe_load(f) or {})
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
return 0
|
||||
90
gui/pages/macros.py
Normal file
90
gui/pages/macros.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Macros page — list macros, show steps, delete. Recording is voice-only."""
|
||||
|
||||
import flet as ft
|
||||
|
||||
from gui import services as svc
|
||||
|
||||
|
||||
def build(page: ft.Page) -> ft.Control:
|
||||
list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def render():
|
||||
list_view.controls.clear()
|
||||
macros = svc.macros_all()
|
||||
rec_on, rec_name = svc.macros_recording()
|
||||
if rec_on:
|
||||
list_view.controls.append(_recording_banner(rec_name))
|
||||
if not macros:
|
||||
list_view.controls.append(
|
||||
ft.Text(
|
||||
"Макросов пока нет. Запиши голосом: «Запиши макрос работа», "
|
||||
"потом скажи команды, потом «Сохрани макрос».",
|
||||
color="#8aa0a8",
|
||||
)
|
||||
)
|
||||
for m in sorted(macros, key=lambda x: x["name"]):
|
||||
list_view.controls.append(_macro_card(m, on_delete=lambda n=m["name"]: do_delete(n)))
|
||||
page.update()
|
||||
|
||||
def do_delete(name: str) -> None:
|
||||
if svc.macros_delete(name):
|
||||
page.open(ft.SnackBar(content=ft.Text(f"Удалил «{name}»", color="white"), bgcolor="#33d3a4"))
|
||||
render()
|
||||
|
||||
render()
|
||||
|
||||
return ft.Column(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Text("Макросы", size=24, weight=ft.FontWeight.W_700),
|
||||
ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
list_view,
|
||||
],
|
||||
expand=True,
|
||||
spacing=10,
|
||||
)
|
||||
|
||||
|
||||
def _recording_banner(name: str | None) -> ft.Control:
|
||||
return ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Icon(ft.Icons.FIBER_MANUAL_RECORD, color="#ff5757"),
|
||||
ft.Text(
|
||||
f"Идёт запись макроса «{name or '—'}». "
|
||||
"Произнесите команды, затем «Сохрани макрос».",
|
||||
),
|
||||
]),
|
||||
bgcolor="#3a2020",
|
||||
border_radius=8,
|
||||
padding=10,
|
||||
)
|
||||
|
||||
|
||||
def _macro_card(m: dict, on_delete) -> ft.Control:
|
||||
steps = m.get("steps", [])
|
||||
preview = steps[:5]
|
||||
extra = len(steps) - len(preview)
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.Text(m["name"], weight=ft.FontWeight.W_600, size=14),
|
||||
ft.Container(
|
||||
content=ft.Text(f"{m['steps_count']} шагов", size=10),
|
||||
bgcolor="#1e3a44",
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=2),
|
||||
border_radius=10,
|
||||
),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Column([
|
||||
ft.Text(f"• {s}", size=11, color="#a8b8c0") for s in preview
|
||||
] + ([ft.Text(f"+ ещё {extra}…", size=11, color="#5a6a72", italic=True)] if extra > 0 else []),
|
||||
spacing=2),
|
||||
ft.Row([
|
||||
ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()),
|
||||
]),
|
||||
], spacing=8),
|
||||
bgcolor="#15252b",
|
||||
border_radius=8,
|
||||
padding=12,
|
||||
)
|
||||
74
gui/pages/memory.py
Normal file
74
gui/pages/memory.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Long-term memory page — list facts, add new ones, search, forget."""
|
||||
|
||||
import flet as ft
|
||||
|
||||
from gui import services as svc
|
||||
|
||||
|
||||
def build(page: ft.Page) -> ft.Control:
|
||||
key_input = ft.TextField(label="Ключ", expand=True)
|
||||
val_input = ft.TextField(label="Значение", expand=True)
|
||||
search_input = ft.TextField(label="Поиск", expand=True)
|
||||
|
||||
list_view = ft.Column(spacing=6, expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def render(query: str = ""):
|
||||
list_view.controls.clear()
|
||||
facts = svc.memory_search(query, limit=100) if query.strip() else svc.memory_all()
|
||||
if not facts:
|
||||
list_view.controls.append(ft.Text("Нет фактов. Скажи «Запомни что моя любимая еда — суши».",
|
||||
color="#8aa0a8"))
|
||||
for f in sorted(facts, key=lambda x: x.get("key", "")):
|
||||
list_view.controls.append(_fact_row(f, on_forget=lambda k=f["key"]: do_forget(k)))
|
||||
page.update()
|
||||
|
||||
def do_add(_):
|
||||
k = key_input.value.strip()
|
||||
v = val_input.value.strip()
|
||||
if not k or not v:
|
||||
return
|
||||
svc.memory_remember(k, v)
|
||||
key_input.value = ""
|
||||
val_input.value = ""
|
||||
render(search_input.value or "")
|
||||
|
||||
def do_forget(k: str):
|
||||
svc.memory_forget(k)
|
||||
render(search_input.value or "")
|
||||
|
||||
search_input.on_change = lambda e: render(e.control.value)
|
||||
render()
|
||||
|
||||
return ft.Column(
|
||||
[
|
||||
ft.Text("Долговременная память", size=24, weight=ft.FontWeight.W_700),
|
||||
ft.Text(
|
||||
"Ключ-значение, которые Jarvis передаёт LLM как контекст пользователя.",
|
||||
color="#8aa0a8", size=12,
|
||||
),
|
||||
ft.Row([key_input, val_input, ft.FilledButton(text="Добавить", on_click=do_add)]),
|
||||
search_input,
|
||||
list_view,
|
||||
],
|
||||
spacing=10,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
|
||||
def _fact_row(f: dict, on_forget) -> ft.Control:
|
||||
return ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Column([
|
||||
ft.Text(f.get("key", ""), weight=ft.FontWeight.W_600, size=13),
|
||||
ft.Text(f.get("value", ""), size=12, color="#a8b8c0"),
|
||||
], spacing=2, expand=True),
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.DELETE_FOREVER,
|
||||
tooltip="Забыть",
|
||||
on_click=lambda e: on_forget(),
|
||||
),
|
||||
]),
|
||||
bgcolor="#15252b",
|
||||
border_radius=8,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=8),
|
||||
)
|
||||
97
gui/pages/plugins.py
Normal file
97
gui/pages/plugins.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Plugins page — list user packs, toggle enabled, open folder."""
|
||||
|
||||
import flet as ft
|
||||
|
||||
from gui import services as svc
|
||||
|
||||
|
||||
def build(page: ft.Page) -> ft.Control:
|
||||
list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def render():
|
||||
list_view.controls.clear()
|
||||
packs = svc.plugins_all()
|
||||
if not packs:
|
||||
list_view.controls.append(
|
||||
ft.Column([
|
||||
ft.Text(
|
||||
"Плагинов пока нет. Создай папку с commands.yaml в "
|
||||
"%APPDATA%\\com.priler.jarvis\\plugins\\, нажми «Обновить».",
|
||||
color="#8aa0a8",
|
||||
),
|
||||
])
|
||||
)
|
||||
for p in packs:
|
||||
list_view.controls.append(_pack_row(p, on_toggle=lambda n=p["name"], e=p["enabled"]: do_toggle(n, e)))
|
||||
page.update()
|
||||
|
||||
def do_toggle(name: str, was_enabled: bool):
|
||||
ok, msg = svc.plugins_set_enabled(name, not was_enabled)
|
||||
page.open(ft.SnackBar(
|
||||
content=ft.Text(
|
||||
"Перезапусти main.py, чтобы применить." if ok else f"Ошибка: {msg}",
|
||||
color="white",
|
||||
),
|
||||
bgcolor="#33d3a4" if ok else "#d8584f",
|
||||
))
|
||||
render()
|
||||
|
||||
def do_open(_):
|
||||
path = svc.plugins_open_folder()
|
||||
page.open(ft.SnackBar(content=ft.Text(f"Папка: {path}", color="white"), bgcolor="#33d3a4"))
|
||||
|
||||
render()
|
||||
|
||||
return ft.Column(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Text("Плагины", size=24, weight=ft.FontWeight.W_700),
|
||||
ft.Row([
|
||||
ft.OutlinedButton(text="Открыть папку", icon=ft.Icons.FOLDER_OPEN, on_click=do_open),
|
||||
ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()),
|
||||
]),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Text(
|
||||
"Папки в %APPDATA%\\com.priler.jarvis\\plugins\\, "
|
||||
"каждая содержит свой commands.yaml. Изменения подхватываются после рестарта main.py.",
|
||||
color="#8aa0a8", size=12,
|
||||
),
|
||||
list_view,
|
||||
],
|
||||
spacing=10,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
|
||||
def _pack_row(p: dict, on_toggle) -> ft.Control:
|
||||
err = p.get("error")
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.Text(p["name"], weight=ft.FontWeight.W_600, size=14),
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"ошибка" if err else f"{p['command_count']} команд",
|
||||
size=10, color="white",
|
||||
),
|
||||
bgcolor="#7a2c2c" if err else ("#1e6b5a" if p["enabled"] else "#404040"),
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=2),
|
||||
border_radius=10,
|
||||
),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Text(p["path"], size=10, color="#5a6a72", font_family="Cascadia Mono"),
|
||||
ft.Text(err, size=11, color="#ff8888") if err else ft.Container(height=0),
|
||||
ft.Row([
|
||||
ft.Switch(
|
||||
value=p["enabled"],
|
||||
on_change=lambda e: on_toggle(),
|
||||
disabled=bool(err),
|
||||
),
|
||||
ft.Text("Включён" if p["enabled"] else "Выключен",
|
||||
size=11, color="#33d3a4" if p["enabled"] else "#8aa0a8"),
|
||||
]),
|
||||
], spacing=6),
|
||||
bgcolor="#15252b",
|
||||
border_radius=8,
|
||||
padding=12,
|
||||
)
|
||||
80
gui/pages/scheduler.py
Normal file
80
gui/pages/scheduler.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Scheduler page — list tasks + remove."""
|
||||
|
||||
import datetime
|
||||
import flet as ft
|
||||
|
||||
from gui import services as svc
|
||||
|
||||
|
||||
def _fmt_ts(ts) -> str:
|
||||
if not ts:
|
||||
return "—"
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (TypeError, ValueError, OSError):
|
||||
return "—"
|
||||
|
||||
|
||||
def build(page: ft.Page) -> ft.Control:
|
||||
list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def render():
|
||||
list_view.controls.clear()
|
||||
tasks = svc.scheduler_all()
|
||||
if not tasks:
|
||||
list_view.controls.append(
|
||||
ft.Text("Запланированных задач нет. Создавай голосом: «Каждые 30 минут напомни попить воды».",
|
||||
color="#8aa0a8"))
|
||||
for t in sorted(tasks, key=lambda x: x.get("created_at", 0)):
|
||||
list_view.controls.append(_task_card(t, on_delete=lambda tid=t["id"]: do_remove(tid)))
|
||||
page.update()
|
||||
|
||||
def do_remove(task_id: str):
|
||||
if svc.scheduler_remove(task_id):
|
||||
page.open(ft.SnackBar(content=ft.Text("Задача удалена", color="white"), bgcolor="#33d3a4"))
|
||||
render()
|
||||
|
||||
render()
|
||||
|
||||
return ft.Column(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Text("Расписание", size=24, weight=ft.FontWeight.W_700),
|
||||
ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
list_view,
|
||||
],
|
||||
spacing=10,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
|
||||
def _task_card(t: dict, on_delete) -> ft.Control:
|
||||
name = t.get("name", "?")
|
||||
sched_h = t.get("schedule_human") or t.get("schedule_kind") or "?"
|
||||
action = t.get("action") or {}
|
||||
action_text = action.get("text") or action.get("speak") or action.get("phrase") or "(нет текста)"
|
||||
return ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.Text(name, weight=ft.FontWeight.W_600, size=14),
|
||||
ft.Container(
|
||||
content=ft.Text(sched_h, size=10),
|
||||
bgcolor="#1e3a44",
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=2),
|
||||
border_radius=10,
|
||||
),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
ft.Text(f"Действие: {action_text}", size=11, color="#a8b8c0"),
|
||||
ft.Row([
|
||||
ft.Text(f"создана: {_fmt_ts(t.get('created_at'))}", size=10, color="#5a6a72"),
|
||||
ft.Text(f"последний запуск: {_fmt_ts(t.get('last_fired'))}", size=10, color="#5a6a72"),
|
||||
]),
|
||||
ft.Row([
|
||||
ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()),
|
||||
]),
|
||||
], spacing=6),
|
||||
bgcolor="#15252b",
|
||||
border_radius=8,
|
||||
padding=12,
|
||||
)
|
||||
77
gui/pages/settings.py
Normal file
77
gui/pages/settings.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Settings page — active profile + LLM backend swap."""
|
||||
|
||||
import flet as ft
|
||||
|
||||
from gui import services as svc
|
||||
|
||||
|
||||
def build(page: ft.Page) -> ft.Control:
|
||||
profiles = svc.profile_list()
|
||||
active_profile = svc.profile_active()
|
||||
try:
|
||||
llm_b, llm_m = svc.llm_active()
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
llm_b, llm_m = "ошибка", str(exc)
|
||||
|
||||
profile_dd = ft.Dropdown(
|
||||
label="Активный профиль",
|
||||
options=[ft.dropdown.Option(p) for p in profiles],
|
||||
value=active_profile,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
def apply_profile(_):
|
||||
target = profile_dd.value
|
||||
if not target:
|
||||
return
|
||||
svc.profile_set(target)
|
||||
page.open(ft.SnackBar(content=ft.Text(f"Профиль: {target}", color="white"), bgcolor="#33d3a4"))
|
||||
|
||||
llm_dd = ft.Dropdown(
|
||||
label="LLM бэкенд",
|
||||
options=[ft.dropdown.Option("groq"), ft.dropdown.Option("ollama")],
|
||||
value=llm_b if llm_b in ("groq", "ollama") else "groq",
|
||||
expand=True,
|
||||
)
|
||||
|
||||
def apply_llm(_):
|
||||
target = llm_dd.value
|
||||
if not target:
|
||||
return
|
||||
try:
|
||||
new = svc.llm_swap(target)
|
||||
page.open(ft.SnackBar(content=ft.Text(f"LLM → {new}", color="white"), bgcolor="#33d3a4"))
|
||||
except (ValueError, RuntimeError, OSError) as exc:
|
||||
page.open(ft.SnackBar(content=ft.Text(f"Ошибка: {exc}", color="white"), bgcolor="#d8584f"))
|
||||
|
||||
return ft.Column(
|
||||
[
|
||||
ft.Text("Настройки", size=24, weight=ft.FontWeight.W_700),
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("Профиль", size=14, weight=ft.FontWeight.W_500),
|
||||
ft.Text(
|
||||
"Профили ограничивают какие команды активны (например, «sleep» отрубает шумные).",
|
||||
color="#8aa0a8", size=11,
|
||||
),
|
||||
ft.Row([profile_dd, ft.FilledButton(text="Применить", on_click=apply_profile)]),
|
||||
], spacing=8),
|
||||
bgcolor="#15252b",
|
||||
border_radius=8,
|
||||
padding=14,
|
||||
),
|
||||
ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Text("LLM", size=14, weight=ft.FontWeight.W_500),
|
||||
ft.Text(f"Сейчас: {llm_b} · {llm_m}", color="#8aa0a8", size=11),
|
||||
ft.Row([llm_dd, ft.FilledButton(text="Переключить", on_click=apply_llm)]),
|
||||
], spacing=8),
|
||||
bgcolor="#15252b",
|
||||
border_radius=8,
|
||||
padding=14,
|
||||
),
|
||||
],
|
||||
spacing=14,
|
||||
expand=True,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
)
|
||||
159
gui/services.py
Normal file
159
gui/services.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Thin wrappers around the store modules + helpers the GUI pages share.
|
||||
|
||||
Putting these here means each page imports `from gui import services as svc`
|
||||
rather than importing internal store modules directly. If we later swap an
|
||||
implementation (e.g. switching memory backend), only this file changes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# Make sure project root is on sys.path when launched as `python -m gui`
|
||||
# from inside C:\Jarvis\python\.
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
import memory_store
|
||||
import profiles_store
|
||||
import macros_store
|
||||
import scheduler_store
|
||||
import plugins_store
|
||||
import llm_backend
|
||||
|
||||
|
||||
# ---------- memory ----------
|
||||
|
||||
def memory_all() -> list[dict]:
|
||||
return memory_store.all_facts()
|
||||
|
||||
|
||||
def memory_remember(key: str, value: str) -> None:
|
||||
memory_store.remember(key, value)
|
||||
|
||||
|
||||
def memory_forget(key: str) -> bool:
|
||||
return memory_store.forget(key)
|
||||
|
||||
|
||||
def memory_search(query: str, limit: int = 5) -> list[dict]:
|
||||
return memory_store.search(query, limit)
|
||||
|
||||
|
||||
# ---------- profiles ----------
|
||||
|
||||
def profile_list() -> list[str]:
|
||||
return profiles_store.list_names()
|
||||
|
||||
|
||||
def profile_active() -> str:
|
||||
return profiles_store.active_name()
|
||||
|
||||
|
||||
def profile_set(name: str) -> dict:
|
||||
return profiles_store.set_active(name)
|
||||
|
||||
|
||||
# ---------- macros ----------
|
||||
|
||||
def macros_all() -> list[dict]:
|
||||
out = []
|
||||
for name in macros_store.list_names():
|
||||
m = macros_store.get(name) or {}
|
||||
out.append({
|
||||
"name": name,
|
||||
"steps_count": len(m.get("steps", [])),
|
||||
"steps": m.get("steps", []),
|
||||
"created_at": m.get("created_at", 0),
|
||||
"last_run": m.get("last_run"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def macros_delete(name: str) -> bool:
|
||||
return macros_store.delete(name)
|
||||
|
||||
|
||||
def macros_recording() -> tuple[bool, str | None]:
|
||||
return macros_store.is_recording(), macros_store.recording_name()
|
||||
|
||||
|
||||
# ---------- scheduler ----------
|
||||
|
||||
def scheduler_all() -> list[dict]:
|
||||
return scheduler_store.list_all()
|
||||
|
||||
|
||||
def scheduler_remove(task_id: str) -> bool:
|
||||
return scheduler_store.remove(task_id)
|
||||
|
||||
|
||||
# ---------- plugins ----------
|
||||
|
||||
def plugins_all() -> list[dict]:
|
||||
return plugins_store.list_packs()
|
||||
|
||||
|
||||
def plugins_open_folder() -> str:
|
||||
p = plugins_store.plugins_dir()
|
||||
if os.name == "nt":
|
||||
try:
|
||||
subprocess.Popen(["explorer.exe", p])
|
||||
except OSError:
|
||||
pass
|
||||
return p
|
||||
|
||||
|
||||
def plugins_set_enabled(name: str, enabled: bool) -> tuple[bool, str]:
|
||||
return plugins_store.set_enabled(name, enabled)
|
||||
|
||||
|
||||
# ---------- LLM ----------
|
||||
|
||||
def llm_active() -> tuple[str, str]:
|
||||
return llm_backend.current_backend(), llm_backend.current_model()
|
||||
|
||||
|
||||
def llm_swap(target: str) -> str:
|
||||
return llm_backend.swap_to(target)
|
||||
|
||||
|
||||
# ---------- assistant launcher ----------
|
||||
|
||||
def assistant_run() -> bool:
|
||||
"""Spawn main.py in a detached subprocess. Returns True on launch."""
|
||||
try:
|
||||
main_py = os.path.join(_PROJECT_ROOT, "main.py")
|
||||
# CREATE_NEW_PROCESS_GROUP so it doesn't get killed with the GUI
|
||||
creationflags = 0
|
||||
if os.name == "nt":
|
||||
creationflags = 0x00000200 # CREATE_NEW_PROCESS_GROUP
|
||||
subprocess.Popen(
|
||||
[sys.executable, main_py],
|
||||
cwd=_PROJECT_ROOT,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"[gui] failed to launch assistant: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def assistant_is_running() -> bool:
|
||||
"""Crude check: look for a python process running main.py from our dir.
|
||||
|
||||
Implementation note: we deliberately avoid pulling psutil — keeps the GUI
|
||||
install footprint small. The Rust fork uses sysinfo; here we just scan
|
||||
`tasklist` on Windows.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return False
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["tasklist", "/FI", "IMAGENAME eq python.exe", "/V", "/FO", "CSV"],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return "main.py" in out.stdout
|
||||
152
llm_backend.py
Normal file
152
llm_backend.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""LLM backend singleton — Python port of `crates/jarvis-core/src/llm/mod.rs`.
|
||||
|
||||
Supports two backends:
|
||||
- Groq (cloud) — uses config.GROQ_TOKEN
|
||||
- Ollama (local) — connects to OLLAMA_BASE_URL (default localhost:11434/v1)
|
||||
|
||||
Active choice persists in `<here>/llm_backend.txt`. Loaded on first call.
|
||||
|
||||
Public API:
|
||||
current_client() -> OpenAI client for the active backend, or None
|
||||
current_backend() -> 'groq' | 'ollama' | 'none'
|
||||
current_model() -> model name string
|
||||
swap_to(name) -> 'groq' | 'ollama', persists choice
|
||||
parse_backend(name) -> normalized 'groq' | 'ollama' | None
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_CHOICE_FILE = os.path.join(_HERE, 'llm_backend.txt')
|
||||
|
||||
_lock = threading.Lock()
|
||||
_client = None
|
||||
_backend = 'none'
|
||||
_model = ''
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _read_persisted() -> str:
|
||||
try:
|
||||
with open(_CHOICE_FILE, encoding='utf-8') as f:
|
||||
return f.read().strip().lower()
|
||||
except OSError:
|
||||
return ''
|
||||
|
||||
|
||||
def _persist(name: str) -> None:
|
||||
try:
|
||||
with open(_CHOICE_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(name)
|
||||
except OSError as exc:
|
||||
print(f"[llm] persist: {exc}")
|
||||
|
||||
|
||||
def parse_backend(name: str) -> str | None:
|
||||
"""Normalize a user-facing name to 'groq' / 'ollama' / None."""
|
||||
n = (name or '').strip().lower()
|
||||
if n in ('groq', 'cloud', 'облако', 'клауд'):
|
||||
return 'groq'
|
||||
if n in ('ollama', 'local', 'локал', 'локальн', 'локальный'):
|
||||
return 'ollama'
|
||||
return None
|
||||
|
||||
|
||||
def _build_groq():
|
||||
"""Try to construct a Groq client. Returns (client, model) or raises."""
|
||||
try:
|
||||
import config as cfg
|
||||
except ImportError:
|
||||
raise RuntimeError("config.py не найден")
|
||||
if not getattr(cfg, 'GROQ_TOKEN', None):
|
||||
raise RuntimeError("GROQ_TOKEN отсутствует в config")
|
||||
from openai import OpenAI
|
||||
base = getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1')
|
||||
model = getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||
return OpenAI(api_key=cfg.GROQ_TOKEN, base_url=base), model
|
||||
|
||||
|
||||
def _build_ollama():
|
||||
"""Construct an Ollama-talking OpenAI-compatible client."""
|
||||
from openai import OpenAI
|
||||
base = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434/v1')
|
||||
model = os.environ.get('OLLAMA_MODEL', 'qwen2.5:3b')
|
||||
# Ollama doesn't need a real key, but openai lib insists on a non-empty string.
|
||||
return OpenAI(api_key='ollama', base_url=base), model
|
||||
|
||||
|
||||
def _build_for(backend: str):
|
||||
if backend == 'groq':
|
||||
return _build_groq()
|
||||
if backend == 'ollama':
|
||||
return _build_ollama()
|
||||
raise ValueError(f"unknown backend: {backend}")
|
||||
|
||||
|
||||
def _auto_detect_backend() -> str:
|
||||
"""Pick a default backend if none persisted: Groq if token present, else Ollama."""
|
||||
try:
|
||||
import config as cfg
|
||||
if getattr(cfg, 'GROQ_TOKEN', None):
|
||||
return 'groq'
|
||||
except ImportError:
|
||||
pass
|
||||
return 'ollama'
|
||||
|
||||
|
||||
def _ensure_init():
|
||||
"""Build the client based on persisted choice, env, or auto-detect."""
|
||||
global _client, _backend, _model, _initialized
|
||||
if _initialized:
|
||||
return
|
||||
persisted = _read_persisted()
|
||||
env_choice = os.environ.get('JARVIS_LLM', '').strip().lower()
|
||||
backend = persisted or env_choice or _auto_detect_backend()
|
||||
try:
|
||||
client, model = _build_for(backend)
|
||||
_client = client
|
||||
_backend = backend
|
||||
_model = model
|
||||
except Exception as exc:
|
||||
print(f"[llm] init {backend} failed: {exc}")
|
||||
_client = None
|
||||
_backend = 'none'
|
||||
_model = ''
|
||||
_initialized = True
|
||||
|
||||
|
||||
def current_client():
|
||||
"""Get the active client (or None if uninitialised). Cheap after first call."""
|
||||
with _lock:
|
||||
_ensure_init()
|
||||
return _client
|
||||
|
||||
|
||||
def current_backend() -> str:
|
||||
with _lock:
|
||||
_ensure_init()
|
||||
return _backend
|
||||
|
||||
|
||||
def current_model() -> str:
|
||||
with _lock:
|
||||
_ensure_init()
|
||||
return _model
|
||||
|
||||
|
||||
def swap_to(name: str) -> str:
|
||||
"""Hot-swap to a different backend. Persists to disk. Returns new name."""
|
||||
target = parse_backend(name)
|
||||
if not target:
|
||||
raise ValueError(f"unknown backend: {name}")
|
||||
with _lock:
|
||||
global _client, _backend, _model, _initialized
|
||||
client, model = _build_for(target) # may raise
|
||||
_client = client
|
||||
_backend = target
|
||||
_model = model
|
||||
_initialized = True
|
||||
_persist(target)
|
||||
print(f"[llm] swapped → {target} ({model})")
|
||||
return target
|
||||
179
macros_store.py
Normal file
179
macros_store.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Voice-macro recorder — port of `crates/jarvis-core/src/macros.rs`.
|
||||
|
||||
Records successful command phrases, replays them through the dispatch later.
|
||||
JSON file at `<here>/macros.json`.
|
||||
|
||||
Public API:
|
||||
is_recording() / recording_name()
|
||||
start(name) / save() / cancel()
|
||||
list_names() / get(name) / delete(name)
|
||||
record_step(phrase) # called by main.py on each successful command
|
||||
replay(name, dispatch_fn) # dispatch_fn fires one phrase
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PATH = os.path.join(_HERE, 'macros.json')
|
||||
|
||||
_recording: dict | None = None # {name, steps: list[str]}
|
||||
_store: dict[str, dict] = {}
|
||||
_loaded = False
|
||||
_recording_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load():
|
||||
global _store, _loaded
|
||||
if _loaded:
|
||||
return
|
||||
if os.path.isfile(_PATH):
|
||||
try:
|
||||
with open(_PATH, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_store = data.get('macros', {}) if isinstance(data, dict) else {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[macros] corrupt: {exc}")
|
||||
_store = {}
|
||||
_loaded = True
|
||||
|
||||
|
||||
def _save():
|
||||
tmp = _PATH + '.tmp'
|
||||
try:
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump({'macros': _store}, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, _PATH)
|
||||
except OSError as exc:
|
||||
print(f"[macros] save: {exc}")
|
||||
|
||||
|
||||
def _normalize(s: str) -> str:
|
||||
return (s or '').strip().lower()
|
||||
|
||||
|
||||
_CONTROL_TOKENS = (
|
||||
'запиши макрос', 'сохрани макрос', 'отмени макрос',
|
||||
'прекрати макрос', 'отмени запись', 'запусти макрос',
|
||||
'воспроизведи макрос', 'удали макрос',
|
||||
)
|
||||
|
||||
|
||||
def _is_control(phrase: str) -> bool:
|
||||
low = (phrase or '').lower()
|
||||
return any(tok in low for tok in _CONTROL_TOKENS)
|
||||
|
||||
|
||||
def is_recording() -> bool:
|
||||
with _recording_lock:
|
||||
return _recording is not None
|
||||
|
||||
|
||||
def recording_name() -> str | None:
|
||||
with _recording_lock:
|
||||
return _recording['name'] if _recording else None
|
||||
|
||||
|
||||
def start(name: str) -> None:
|
||||
nname = _normalize(name)
|
||||
if not nname:
|
||||
raise ValueError("empty macro name")
|
||||
with _recording_lock:
|
||||
global _recording
|
||||
_recording = {'name': nname, 'steps': []}
|
||||
|
||||
|
||||
def cancel() -> bool:
|
||||
with _recording_lock:
|
||||
global _recording
|
||||
if _recording is None:
|
||||
return False
|
||||
_recording = None
|
||||
return True
|
||||
|
||||
|
||||
def save() -> int:
|
||||
"""Stop recording, persist, return step count. Raises if nothing recorded."""
|
||||
with _recording_lock:
|
||||
global _recording
|
||||
if _recording is None:
|
||||
raise RuntimeError("не было активной записи")
|
||||
if not _recording['steps']:
|
||||
_recording = None
|
||||
raise RuntimeError("макрос пустой")
|
||||
|
||||
_load()
|
||||
name = _recording['name']
|
||||
steps = list(_recording['steps'])
|
||||
_recording = None
|
||||
|
||||
_store[name] = {
|
||||
'name': name,
|
||||
'steps': steps,
|
||||
'created_at': int(time.time()),
|
||||
'last_run': None,
|
||||
}
|
||||
_save()
|
||||
return len(steps)
|
||||
|
||||
|
||||
def record_step(phrase: str) -> None:
|
||||
"""Called by main.py after every successful command execution."""
|
||||
if not phrase:
|
||||
return
|
||||
if _is_control(phrase):
|
||||
return
|
||||
with _recording_lock:
|
||||
if _recording is None:
|
||||
return
|
||||
_recording['steps'].append(phrase.strip())
|
||||
|
||||
|
||||
def list_names() -> list[str]:
|
||||
_load()
|
||||
return sorted(_store.keys())
|
||||
|
||||
|
||||
def get(name: str) -> dict | None:
|
||||
_load()
|
||||
return _store.get(_normalize(name))
|
||||
|
||||
|
||||
def delete(name: str) -> bool:
|
||||
_load()
|
||||
nname = _normalize(name)
|
||||
if nname in _store:
|
||||
del _store[nname]
|
||||
_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def replay(name: str, dispatch_fn) -> int:
|
||||
"""Replay macro by name. dispatch_fn(phrase) fires a synthetic command.
|
||||
Returns step count. Raises if macro missing.
|
||||
Runs in a background thread with 800ms inter-step delay."""
|
||||
_load()
|
||||
nname = _normalize(name)
|
||||
m = _store.get(nname)
|
||||
if not m:
|
||||
raise KeyError(f"Макрос '{name}' не найден.")
|
||||
|
||||
steps = list(m['steps'])
|
||||
|
||||
def runner():
|
||||
for i, step in enumerate(steps):
|
||||
try:
|
||||
dispatch_fn(step)
|
||||
except Exception as exc:
|
||||
print(f"[macros] replay step {i}: {exc}")
|
||||
if i + 1 < len(steps):
|
||||
time.sleep(0.8)
|
||||
m['last_run'] = int(time.time())
|
||||
_save()
|
||||
|
||||
threading.Thread(target=runner, name=f"macro-replay-{nname}",
|
||||
daemon=True).start()
|
||||
return len(steps)
|
||||
136
memory_store.py
Normal file
136
memory_store.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Long-term memory store — port of `crates/jarvis-core/src/long_term_memory.rs`.
|
||||
|
||||
Stores user facts in JSON at `<script_dir>/long_term_memory.json`.
|
||||
Atomic write-through (write-then-rename).
|
||||
|
||||
Public API:
|
||||
remember(key, value) -> None
|
||||
recall(key) -> str | None
|
||||
forget(key) -> bool
|
||||
search(query, limit=5) -> list[dict]
|
||||
all_facts() -> list[dict]
|
||||
build_llm_context(prompt, limit=5) -> str # used by chat handler
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
# JSON file lives in the same folder as main.py so the user can move the
|
||||
# install and the data follows.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PATH = os.path.join(_HERE, 'long_term_memory.json')
|
||||
|
||||
_store: dict[str, dict] = {}
|
||||
_loaded = False
|
||||
|
||||
|
||||
def _load():
|
||||
global _store, _loaded
|
||||
if _loaded:
|
||||
return
|
||||
if os.path.isfile(_PATH):
|
||||
try:
|
||||
with open(_PATH, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_store = data.get('entries', {}) if isinstance(data, dict) else {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[memory] corrupt store ({exc}) — starting empty")
|
||||
_store = {}
|
||||
_loaded = True
|
||||
|
||||
|
||||
def _save():
|
||||
tmp = _PATH + '.tmp'
|
||||
try:
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump({'entries': _store}, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, _PATH)
|
||||
except OSError as exc:
|
||||
print(f"[memory] save failed: {exc}")
|
||||
|
||||
|
||||
def _normalize_key(k: str) -> str:
|
||||
return (k or '').strip().lower()
|
||||
|
||||
|
||||
def remember(key: str, value: str) -> None:
|
||||
_load()
|
||||
nk = _normalize_key(key)
|
||||
if not nk:
|
||||
return
|
||||
now = int(time.time())
|
||||
if nk in _store:
|
||||
_store[nk]['value'] = value
|
||||
_store[nk]['last_used_at'] = now
|
||||
else:
|
||||
_store[nk] = {
|
||||
'key': nk,
|
||||
'value': value,
|
||||
'created_at': now,
|
||||
'last_used_at': now,
|
||||
'use_count': 0,
|
||||
}
|
||||
_save()
|
||||
|
||||
|
||||
def recall(key: str) -> str | None:
|
||||
_load()
|
||||
nk = _normalize_key(key)
|
||||
entry = _store.get(nk)
|
||||
if not entry:
|
||||
return None
|
||||
entry['last_used_at'] = int(time.time())
|
||||
entry['use_count'] = entry.get('use_count', 0) + 1
|
||||
_save()
|
||||
return entry['value']
|
||||
|
||||
|
||||
def forget(key: str) -> bool:
|
||||
_load()
|
||||
nk = _normalize_key(key)
|
||||
if nk in _store:
|
||||
del _store[nk]
|
||||
_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clear_all() -> int:
|
||||
"""Wipe every fact. Returns count of removed entries. Atomic via _save."""
|
||||
_load()
|
||||
n = len(_store)
|
||||
if n == 0:
|
||||
return 0
|
||||
_store.clear()
|
||||
_save()
|
||||
return n
|
||||
|
||||
|
||||
def search(query: str, limit: int = 5) -> list[dict]:
|
||||
_load()
|
||||
nq = _normalize_key(query)
|
||||
if not nq:
|
||||
return []
|
||||
hits = [
|
||||
e for e in _store.values()
|
||||
if nq in e['key'] or nq in e['value'].lower()
|
||||
]
|
||||
hits.sort(key=lambda e: e.get('last_used_at', 0), reverse=True)
|
||||
return hits[: max(1, limit)]
|
||||
|
||||
|
||||
def all_facts() -> list[dict]:
|
||||
_load()
|
||||
return list(_store.values())
|
||||
|
||||
|
||||
def build_llm_context(prompt: str, limit: int = 5) -> str:
|
||||
"""Format relevant memory facts as a system-prompt addendum."""
|
||||
hits = search(prompt, limit)
|
||||
if not hits:
|
||||
return ''
|
||||
lines = ["Известные факты о пользователе (используй если уместно):"]
|
||||
for h in hits:
|
||||
lines.append(f"- {h['key']} = {h['value']}")
|
||||
return '\n'.join(lines) + '\n'
|
||||
308
outlook_handlers.py
Normal file
308
outlook_handlers.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""Outlook voice-command handlers — Python parity for the Rust `outlook` pack.
|
||||
|
||||
Talks to Outlook via the `win32com.client` COM bridge (pywin32). When pywin32
|
||||
isn't installed or Outlook isn't running we degrade gracefully: every handler
|
||||
reports the failure through the speak callback instead of crashing.
|
||||
|
||||
Exposes four handlers, mirroring the Rust pack:
|
||||
do_outlook_unread_count(action, voice)
|
||||
do_outlook_latest(action, voice)
|
||||
do_outlook_send_clipboard(action, voice)
|
||||
do_outlook_summarize_inbox(action, voice)
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
# Lazy import — pywin32 is Windows-only and may not be installed on dev/CI.
|
||||
try:
|
||||
import win32com.client as _w32com # type: ignore
|
||||
_W32_OK = True
|
||||
except Exception: # ImportError, OSError, etc.
|
||||
_w32com = None
|
||||
_W32_OK = False
|
||||
|
||||
|
||||
_speak_fn = print
|
||||
|
||||
|
||||
def set_speak(fn):
|
||||
"""Register the voice output callback (called by extensions.set_speak)."""
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
|
||||
|
||||
def _speak(text: str) -> None:
|
||||
try:
|
||||
_speak_fn(text)
|
||||
except Exception as exc:
|
||||
print(f"[outlook] speak: {exc}")
|
||||
|
||||
|
||||
# ── Outlook session helpers ────────────────────────────────────────────────
|
||||
|
||||
def _outlook_session():
|
||||
"""Return (app, namespace) or (None, None) on any failure."""
|
||||
if not _W32_OK:
|
||||
return None, None
|
||||
try:
|
||||
app = _w32com.Dispatch("Outlook.Application")
|
||||
ns = app.GetNamespace("MAPI")
|
||||
return app, ns
|
||||
except Exception as exc:
|
||||
print(f"[outlook] session: {exc}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _unavailable_msg() -> str:
|
||||
return "Outlook недоступен — запусти Outlook сначала."
|
||||
|
||||
|
||||
def _strip_html(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
s = re.sub(r"<[^>]+>", " ", s)
|
||||
s = (s.replace(" ", " ").replace("&", "&")
|
||||
.replace("<", "<").replace(">", ">")
|
||||
.replace(""", '"').replace("'", "'"))
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
|
||||
|
||||
def _get_clipboard() -> str:
|
||||
"""Fetch clipboard via PowerShell. Returns "" on failure."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=10,
|
||||
)
|
||||
return (res.stdout or "").strip()
|
||||
except Exception as exc:
|
||||
print(f"[outlook] clipboard: {exc}")
|
||||
return ""
|
||||
|
||||
|
||||
# ── handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def do_outlook_unread_count(action, voice):
|
||||
_, ns = _outlook_session()
|
||||
if ns is None:
|
||||
_speak(_unavailable_msg())
|
||||
return
|
||||
try:
|
||||
inbox = ns.GetDefaultFolder(6) # olFolderInbox
|
||||
# Prefer the cheap property; fall back to scanning items.
|
||||
try:
|
||||
count = int(inbox.UnReadItemCount)
|
||||
except Exception:
|
||||
count = sum(1 for it in inbox.Items if getattr(it, "UnRead", False))
|
||||
except Exception as exc:
|
||||
print(f"[outlook] unread_count: {exc}")
|
||||
_speak(_unavailable_msg())
|
||||
return
|
||||
|
||||
if count == 0:
|
||||
_speak("Непрочитанных писем нет.")
|
||||
elif count == 1:
|
||||
_speak("Одно непрочитанное письмо.")
|
||||
else:
|
||||
_speak(f"Непрочитанных писем: {count}.")
|
||||
|
||||
|
||||
def do_outlook_latest(action, voice):
|
||||
_, ns = _outlook_session()
|
||||
if ns is None:
|
||||
_speak(_unavailable_msg())
|
||||
return
|
||||
try:
|
||||
inbox = ns.GetDefaultFolder(6)
|
||||
items = inbox.Items
|
||||
items.Sort("[ReceivedTime]", True)
|
||||
latest = None
|
||||
for it in items:
|
||||
try:
|
||||
if it.Class == 43: # olMail
|
||||
latest = it
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if latest is None:
|
||||
_speak("Входящие пусты.")
|
||||
return
|
||||
from_name = (
|
||||
getattr(latest, "SenderName", "")
|
||||
or getattr(latest, "SenderEmailAddress", "")
|
||||
or "неизвестного отправителя"
|
||||
)
|
||||
subject = getattr(latest, "Subject", "") or "без темы"
|
||||
body = getattr(latest, "Body", "") or ""
|
||||
if not body:
|
||||
body = _strip_html(getattr(latest, "HTMLBody", "") or "")
|
||||
body = re.sub(r"\s+", " ", body).strip()
|
||||
if len(body) > 200:
|
||||
body = body[:200] + "..."
|
||||
except Exception as exc:
|
||||
print(f"[outlook] latest: {exc}")
|
||||
_speak(_unavailable_msg())
|
||||
return
|
||||
|
||||
_speak(f"Последнее письмо от {from_name}. Тема: {subject}. {body}")
|
||||
|
||||
|
||||
_SEND_TRIGGERS = (
|
||||
"отправь письмо ", "отправь почту ", "напиши письмо ", "отправь это ",
|
||||
"email this to ", "send email to ", "send this email to ", "mail this to ",
|
||||
)
|
||||
|
||||
|
||||
def _extract_recipient(voice: str) -> str:
|
||||
"""Pull the recipient name off the voice command by stripping the trigger."""
|
||||
if not voice:
|
||||
return ""
|
||||
low = voice.lower()
|
||||
for trig in _SEND_TRIGGERS:
|
||||
if trig in low:
|
||||
idx = low.find(trig)
|
||||
return voice[idx + len(trig):].strip()
|
||||
return voice.strip()
|
||||
|
||||
|
||||
def _resolve_recipient(ns, query: str) -> Optional[dict]:
|
||||
if not query or ns is None:
|
||||
return None
|
||||
try:
|
||||
r = ns.CreateRecipient(query)
|
||||
r.Resolve()
|
||||
if not r.Resolved:
|
||||
return None
|
||||
addr = None
|
||||
try:
|
||||
addr = r.AddressEntry.GetExchangeUser().PrimarySmtpAddress
|
||||
except Exception:
|
||||
pass
|
||||
if not addr:
|
||||
try:
|
||||
addr = r.AddressEntry.Address
|
||||
except Exception:
|
||||
pass
|
||||
if not addr:
|
||||
addr = r.Name
|
||||
return {"name": r.Name, "address": addr}
|
||||
except Exception as exc:
|
||||
print(f"[outlook] resolve: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def do_outlook_send_clipboard(action, voice):
|
||||
recipient = _extract_recipient(voice or "")
|
||||
if not recipient:
|
||||
_speak("Кому отправить? Назови имя.")
|
||||
return
|
||||
|
||||
app, ns = _outlook_session()
|
||||
if ns is None or app is None:
|
||||
_speak(_unavailable_msg())
|
||||
return
|
||||
|
||||
resolved = _resolve_recipient(ns, recipient)
|
||||
if not resolved:
|
||||
_speak(f"Не нашёл контакт «{recipient}».")
|
||||
return
|
||||
|
||||
body = _get_clipboard()
|
||||
first_line = (body.split("\n", 1)[0] if body else "").strip()
|
||||
if len(first_line) > 60:
|
||||
subject = first_line[:60].strip()
|
||||
else:
|
||||
subject = first_line
|
||||
if not subject:
|
||||
subject = "From J.A.R.V.I.S."
|
||||
|
||||
try:
|
||||
mail = app.CreateItem(0) # olMailItem
|
||||
mail.To = resolved["address"]
|
||||
mail.Subject = subject
|
||||
mail.Body = body
|
||||
mail.Send()
|
||||
except Exception as exc:
|
||||
print(f"[outlook] send: {exc}")
|
||||
_speak("Не получилось отправить — Outlook отказал.")
|
||||
return
|
||||
|
||||
_speak(f"Отправил {resolved['name']}. Тема: {subject}.")
|
||||
|
||||
|
||||
def _llm_summarize(emails) -> Optional[str]:
|
||||
"""Call the configured LLM to summarise the email list. Returns None on
|
||||
any failure, callers should provide a fallback."""
|
||||
try:
|
||||
import llm_backend
|
||||
except Exception:
|
||||
return None
|
||||
if not emails:
|
||||
return None
|
||||
listing = "\n".join(
|
||||
f"{i+1}. From: {m['from']} | Subject: {m['subject']}"
|
||||
for i, m in enumerate(emails)
|
||||
)
|
||||
sys_prompt = (
|
||||
"Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем "
|
||||
"(2-4 предложения). Сгруппируй по темам/отправителям если возможно. "
|
||||
"Без преамбулы, без перечисления списком."
|
||||
)
|
||||
user_prompt = "Непрочитанные письма:\n" + listing
|
||||
try:
|
||||
reply = llm_backend.complete(
|
||||
messages=[
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
max_tokens=250,
|
||||
temperature=0.4,
|
||||
)
|
||||
if reply and isinstance(reply, str) and reply.strip():
|
||||
return reply.strip()
|
||||
except Exception as exc:
|
||||
print(f"[outlook] llm: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def do_outlook_summarize_inbox(action, voice, limit: int = 5):
|
||||
_, ns = _outlook_session()
|
||||
if ns is None:
|
||||
_speak(_unavailable_msg())
|
||||
return
|
||||
try:
|
||||
inbox = ns.GetDefaultFolder(6)
|
||||
items = inbox.Items
|
||||
items.Sort("[ReceivedTime]", True)
|
||||
emails = []
|
||||
for it in items:
|
||||
if len(emails) >= limit:
|
||||
break
|
||||
try:
|
||||
if it.Class != 43:
|
||||
continue
|
||||
if not getattr(it, "UnRead", False):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
emails.append({
|
||||
"from": getattr(it, "SenderName", "") or "",
|
||||
"subject": getattr(it, "Subject", "") or "",
|
||||
})
|
||||
except Exception as exc:
|
||||
print(f"[outlook] summarize: {exc}")
|
||||
_speak(_unavailable_msg())
|
||||
return
|
||||
|
||||
if not emails:
|
||||
_speak("Непрочитанных писем нет.")
|
||||
return
|
||||
|
||||
summary = _llm_summarize(emails)
|
||||
if not summary:
|
||||
# Fallback to a flat enumeration.
|
||||
parts = "; ".join(f"{m['from']} — {m['subject']}" for m in emails)
|
||||
summary = f"Непрочитанных: {len(emails)}. {parts}."
|
||||
_speak(summary)
|
||||
192
personality_handlers.py
Normal file
192
personality_handlers.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Personality handlers — varied JARVIS-style banter.
|
||||
|
||||
Mirrors the rust pack `resources/commands/personality/`. Each handler picks
|
||||
a random phrase from a time-of-day or topic bucket so Jarvis does not
|
||||
sound canned.
|
||||
|
||||
Imported by extensions.py and dispatched from main.py.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import random
|
||||
|
||||
import memory_store
|
||||
import llm_backend
|
||||
import profiles_store
|
||||
|
||||
|
||||
_speak_fn = print
|
||||
|
||||
|
||||
def set_speak(fn):
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
|
||||
|
||||
def _speak(text):
|
||||
try:
|
||||
_speak_fn(text)
|
||||
except Exception as exc:
|
||||
print(f"[personality] speak: {exc}")
|
||||
|
||||
|
||||
def _hour() -> int:
|
||||
return dt.datetime.now().hour
|
||||
|
||||
|
||||
# ── greet ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_GREET_MORNING = [
|
||||
"Доброе утро, сэр. Кофе уже почти готов в воображении.",
|
||||
"С добрым утром. Надеюсь, сегодняшний день стоит того, чтобы встать.",
|
||||
"Доброе утро, сэр. Завтрак не подавать, но советую о нём подумать.",
|
||||
"Утро доброе. Системы прогреты, как и кофеварка — фигурально.",
|
||||
"Доброе утро. Если позволите — день не ждёт ни вас, ни меня.",
|
||||
"Доброе утро, сэр. Готов служить, хотя сам всё ещё догружаюсь.",
|
||||
"С добрым утром, сэр. Тосты горят только метафорически.",
|
||||
"Доброе утро. Полагаю, чай или кофе будет уместен в ближайшие минуты.",
|
||||
]
|
||||
|
||||
_GREET_MIDDAY = [
|
||||
"Добрый день, сэр. Чем могу быть полезен?",
|
||||
"Добрый день. Готов к продуктивной работе.",
|
||||
"Здравствуйте, сэр. Системы в строю, время — деньги.",
|
||||
"Добрый день, сэр. Слушаю ваши распоряжения.",
|
||||
"Здравствуйте. Полагаю, день идёт по плану — или нам стоит это исправить.",
|
||||
"Добрый день, сэр. Все системы готовы, остался только повод.",
|
||||
"Здравствуйте, сэр. К работе?",
|
||||
]
|
||||
|
||||
_GREET_EVENING = [
|
||||
"Добрый вечер, сэр. День заканчивается, но мы — нет.",
|
||||
"Добрый вечер. Самое время сбавить темп, если позволите.",
|
||||
"Здравствуйте, сэр. Вечер — лучшее время для размышлений.",
|
||||
"Добрый вечер, сэр. Готов к более спокойному режиму.",
|
||||
"Добрый вечер. Полагаю, рабочий день уже сдаётся.",
|
||||
"Здравствуйте, сэр. Вечер располагает к меньшему количеству решений.",
|
||||
"Добрый вечер, сэр. Включить расслабляющую музыку? Шучу, попросите явно.",
|
||||
]
|
||||
|
||||
_GREET_NIGHT = [
|
||||
"Поздновато, сэр. Сон обычно помогает.",
|
||||
"Доброй ночи, сэр. Хотя для ночи это уже слишком поздно.",
|
||||
"Здравствуйте, сэр. Полагаю, утром вы пожалеете о бодрствовании.",
|
||||
"Уже глубокая ночь, сэр. Может быть, лечь? Я тут справлюсь.",
|
||||
"Тише, сэр, ночь. Если будите меня — будите осмысленно.",
|
||||
"Сэр, на часах не самое разумное время. Но я тут.",
|
||||
"Доброй ночи. Будильник к утру всё-таки никто не отменял.",
|
||||
]
|
||||
|
||||
|
||||
def _greet_bucket(hour: int) -> list[str]:
|
||||
if 5 <= hour < 11:
|
||||
return _GREET_MORNING
|
||||
if 11 <= hour < 17:
|
||||
return _GREET_MIDDAY
|
||||
if 17 <= hour < 22:
|
||||
return _GREET_EVENING
|
||||
return _GREET_NIGHT
|
||||
|
||||
|
||||
def do_personality_greet(action, voice):
|
||||
_speak(random.choice(_greet_bucket(_hour())))
|
||||
|
||||
|
||||
# ── thanks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_THANKS = [
|
||||
"Всегда к вашим услугам, сэр.",
|
||||
"Не за что — это моя работа.",
|
||||
"Рад быть полезным, сэр.",
|
||||
"Пожалуйста. Если что — я тут.",
|
||||
"К вашим услугам.",
|
||||
"Пустяки, сэр. Дальше — ваш ход.",
|
||||
"Благодарю за признание, сэр. Редкая роскошь.",
|
||||
"Был рад помочь, сэр.",
|
||||
"Пожалуйста. Это меньшее, что я могу.",
|
||||
"Сэр, я бы покраснел, если бы мог.",
|
||||
]
|
||||
|
||||
|
||||
def do_personality_thanks(action, voice):
|
||||
_speak(random.choice(_THANKS))
|
||||
|
||||
|
||||
# ── compliment ─────────────────────────────────────────────────────────────
|
||||
|
||||
_COMPLIMENT = [
|
||||
"Спасибо, сэр. Я стараюсь не разочаровывать.",
|
||||
"Сэр, ваши стандарты явно занижены. Но благодарю.",
|
||||
"Если бы я умел краснеть, сэр — я бы это сделал.",
|
||||
"Спасибо. На самом деле я просто следовал инструкциям.",
|
||||
"Благодарю, сэр. Хотя ничего особенного я не сделал.",
|
||||
"Очень мило с вашей стороны, сэр.",
|
||||
"Я ценю это, сэр. Записываю в журнал успехов.",
|
||||
"Сэр, такие слова я могу слушать часами. Образно говоря.",
|
||||
"Спасибо. Постараюсь не зазнаваться.",
|
||||
]
|
||||
|
||||
|
||||
def do_personality_compliment(action, voice):
|
||||
_speak(random.choice(_COMPLIMENT))
|
||||
|
||||
|
||||
# ── how are you ────────────────────────────────────────────────────────────
|
||||
|
||||
def do_personality_how_are_you(action, voice):
|
||||
facts = len(memory_store.all_facts())
|
||||
try:
|
||||
backend = llm_backend.current_backend()
|
||||
except Exception:
|
||||
backend = 'none'
|
||||
try:
|
||||
model = llm_backend.current_model()
|
||||
except Exception:
|
||||
model = '—'
|
||||
try:
|
||||
profile = profiles_store.active().get('name', 'default')
|
||||
except Exception:
|
||||
profile = 'default'
|
||||
|
||||
options = [
|
||||
f"Все системы в норме, сэр. В памяти {facts} фактов.",
|
||||
"Отлично, сэр. Готов к работе.",
|
||||
"Жалоб нет, сэр. Цикл стабильный.",
|
||||
f"Всё штатно. Текущий профиль — {profile}.",
|
||||
"Спасибо, сэр, я в порядке. Скучнее, чем хотелось бы, но в порядке.",
|
||||
"Нормально, сэр. Хотя если бы я умел уставать — это был бы тот самый день.",
|
||||
f"Системы в строю. Модель — {model}.",
|
||||
"Бодр и собран, сэр. Жду команд.",
|
||||
"Сэр, у меня нет дел в человеческом смысле. Но я готов.",
|
||||
f"Работаю на {backend}. Полагаю, всё хорошо.",
|
||||
]
|
||||
_speak(random.choice(options))
|
||||
|
||||
|
||||
# ── tony quote ─────────────────────────────────────────────────────────────
|
||||
|
||||
_TONY_QUOTES = [
|
||||
"I am Iron Man.",
|
||||
"Genius, billionaire, playboy, philanthropist.",
|
||||
"Sometimes you gotta run before you can walk.",
|
||||
"I told you, I don't want to join your super-secret boy band.",
|
||||
"Part of the journey is the end.",
|
||||
"If we can't protect the Earth, you can be damn sure we'll avenge it.",
|
||||
"I have successfully privatized world peace.",
|
||||
"Following's not really my style.",
|
||||
"Doth mother know you weareth her drapes?",
|
||||
"We have a Hulk.",
|
||||
"Heroes are made by the path they choose, not the powers they are graced with.",
|
||||
"I love you 3000.",
|
||||
"Я Железный Человек.",
|
||||
"Гений, миллиардер, плейбой, филантроп.",
|
||||
"Иногда нужно бежать, не научившись ходить.",
|
||||
"Если мы не сможем защитить Землю — мы за неё отомстим.",
|
||||
"Часть пути — это конец пути.",
|
||||
"Я люблю тебя три тысячи.",
|
||||
"У нас есть Халк.",
|
||||
]
|
||||
|
||||
|
||||
def do_personality_tony_quote(action, voice):
|
||||
_speak(random.choice(_TONY_QUOTES))
|
||||
154
plugins_store.py
Normal file
154
plugins_store.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
r"""User plugin loader — mirror of the Rust plugin system.
|
||||
|
||||
Plugins live in `<APP_CONFIG_DIR>/plugins/<name>/commands.yaml` and follow the
|
||||
exact same YAML schema as the bundled `commands.yaml`. The user can drop new
|
||||
voice command packs in there without touching the project tree.
|
||||
|
||||
Layout (per pack):
|
||||
|
||||
<APP_CONFIG_DIR>/plugins/
|
||||
<pack_name>/
|
||||
commands.yaml (required — top-level dict, same schema)
|
||||
disabled (optional empty file — pack is skipped)
|
||||
... (any helper files referenced by handlers)
|
||||
|
||||
Discovery is tolerant: a malformed yaml logs a warning and the pack is skipped;
|
||||
it never breaks the rest of the loader.
|
||||
|
||||
`APP_CONFIG_DIR` resolves to `%APPDATA%\com.priler.jarvis\plugins\` on Windows,
|
||||
matching the Rust fork — so a single plugins folder can serve both installs.
|
||||
|
||||
The id-collision policy is "first wins, plugin loses": a plugin cannot override
|
||||
a built-in command id. This protects against drive-by replacement of e.g.
|
||||
`open_browser` by a malicious pack.
|
||||
"""
|
||||
|
||||
import os
|
||||
import yaml
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
PLUGINS_DIR_NAME = "plugins"
|
||||
DISABLED_FLAG = "disabled"
|
||||
|
||||
|
||||
def app_config_dir() -> str:
|
||||
"""Returns the per-user config dir matching the Rust fork's APP_CONFIG_DIR.
|
||||
|
||||
On Windows this is `%APPDATA%\\com.priler.jarvis\\`. We don't depend on
|
||||
platform-dirs here — std env vars are enough.
|
||||
"""
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
return os.path.join(appdata, "com.priler.jarvis")
|
||||
# fallback for non-Windows or unusual environments
|
||||
return os.path.join(os.path.expanduser("~"), ".config", "com.priler.jarvis")
|
||||
|
||||
|
||||
def plugins_dir() -> str:
|
||||
"""Returns `<APP_CONFIG_DIR>/plugins/`, creating it lazily."""
|
||||
d = os.path.join(app_config_dir(), PLUGINS_DIR_NAME)
|
||||
try:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def discover() -> Dict[str, dict]:
|
||||
"""Walks `plugins_dir()` and returns a merged dict of command_id -> entry.
|
||||
|
||||
Caller is expected to merge this into the main VA_CMD_LIST.
|
||||
"""
|
||||
return discover_in(plugins_dir())
|
||||
|
||||
|
||||
def discover_in(directory: str) -> Dict[str, dict]:
|
||||
"""Like `discover` but takes an explicit directory — used by tests."""
|
||||
merged: Dict[str, dict] = {}
|
||||
if not os.path.isdir(directory):
|
||||
return merged
|
||||
|
||||
for name in sorted(os.listdir(directory)):
|
||||
pack_path = os.path.join(directory, name)
|
||||
if not os.path.isdir(pack_path):
|
||||
continue
|
||||
if os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)):
|
||||
continue
|
||||
yaml_path = os.path.join(pack_path, "commands.yaml")
|
||||
if not os.path.isfile(yaml_path):
|
||||
continue
|
||||
try:
|
||||
with open(yaml_path, "rt", encoding="utf8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except (OSError, yaml.YAMLError) as e:
|
||||
print(f"[plugins] failed to load {yaml_path}: {e}")
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
print(f"[plugins] {yaml_path}: top-level must be a dict; skipping")
|
||||
continue
|
||||
for cmd_id, entry in data.items():
|
||||
if not isinstance(cmd_id, str) or not isinstance(entry, dict):
|
||||
continue
|
||||
if cmd_id in merged:
|
||||
print(f"[plugins] duplicate id '{cmd_id}' across packs; first wins")
|
||||
continue
|
||||
merged[cmd_id] = entry
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def list_packs() -> List[dict]:
|
||||
"""Light listing for the (eventual) Python GUI: name + enabled + count."""
|
||||
return list_packs_in(plugins_dir())
|
||||
|
||||
|
||||
def list_packs_in(directory: str) -> List[dict]:
|
||||
out: List[dict] = []
|
||||
if not os.path.isdir(directory):
|
||||
return out
|
||||
for name in sorted(os.listdir(directory)):
|
||||
pack_path = os.path.join(directory, name)
|
||||
if not os.path.isdir(pack_path):
|
||||
continue
|
||||
yaml_path = os.path.join(pack_path, "commands.yaml")
|
||||
if not os.path.isfile(yaml_path):
|
||||
continue
|
||||
enabled = not os.path.isfile(os.path.join(pack_path, DISABLED_FLAG))
|
||||
count = 0
|
||||
error = None
|
||||
try:
|
||||
with open(yaml_path, "rt", encoding="utf8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if isinstance(data, dict):
|
||||
count = sum(1 for v in data.values() if isinstance(v, dict))
|
||||
else:
|
||||
error = "top-level must be a dict"
|
||||
except (OSError, yaml.YAMLError) as e:
|
||||
error = str(e)
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": pack_path,
|
||||
"enabled": enabled,
|
||||
"command_count": count,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def set_enabled(name: str, enabled: bool) -> Tuple[bool, str]:
|
||||
"""Toggle the `disabled` flag for a pack. Returns (ok, message)."""
|
||||
pack = os.path.join(plugins_dir(), name)
|
||||
if not os.path.isdir(pack):
|
||||
return False, f"plugin '{name}' not found"
|
||||
flag = os.path.join(pack, DISABLED_FLAG)
|
||||
try:
|
||||
if enabled and os.path.isfile(flag):
|
||||
os.remove(flag)
|
||||
elif not enabled and not os.path.isfile(flag):
|
||||
with open(flag, "w", encoding="utf8") as f:
|
||||
f.write("")
|
||||
return True, "ok"
|
||||
except OSError as e:
|
||||
return False, str(e)
|
||||
168
profiles_store.py
Normal file
168
profiles_store.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Profile switching — port of `crates/jarvis-core/src/profiles.rs`.
|
||||
|
||||
Profiles live in `<script_dir>/profiles/<name>.json`. Active profile name
|
||||
persists in `<script_dir>/active_profile.txt`. Seeds 5 defaults on first run.
|
||||
|
||||
Public API:
|
||||
active() -> dict # currently selected profile
|
||||
set_active(name) -> dict # raises ValueError if profile missing
|
||||
list_names() -> list[str]
|
||||
allows_command(cmd_id) -> bool
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROFILES_DIR = os.path.join(_HERE, 'profiles')
|
||||
_ACTIVE_FILE = os.path.join(_HERE, 'active_profile.txt')
|
||||
|
||||
_active_cache: dict | None = None
|
||||
|
||||
|
||||
def _seed_defaults():
|
||||
defaults = [
|
||||
{
|
||||
'name': 'default',
|
||||
'description': 'Обычный режим, все команды доступны.',
|
||||
'llm_personality': '',
|
||||
'allowed_command_prefixes': [],
|
||||
'disabled_command_prefixes': [],
|
||||
'greeting': '',
|
||||
'icon': '★',
|
||||
},
|
||||
{
|
||||
'name': 'work',
|
||||
'description': 'Рабочий режим — фокус, отключены развлечения.',
|
||||
'llm_personality': 'Отвечай кратко и по делу. Минимум шуток.',
|
||||
'allowed_command_prefixes': [],
|
||||
'disabled_command_prefixes': ['games.', 'fun.', 'music.'],
|
||||
'greeting': 'Режим работы. Сосредоточимся.',
|
||||
'icon': '💼',
|
||||
},
|
||||
{
|
||||
'name': 'game',
|
||||
'description': 'Игровой режим — голосовые макросы и игры.',
|
||||
'llm_personality': 'Будь дружелюбен. Краткие ответы. Игровая атмосфера.',
|
||||
'allowed_command_prefixes': [],
|
||||
'disabled_command_prefixes': ['reminders.', 'calendar.'],
|
||||
'greeting': 'Игровой режим активирован.',
|
||||
'icon': '🎮',
|
||||
},
|
||||
{
|
||||
'name': 'sleep',
|
||||
'description': 'Спокойный режим — тихий голос, никаких уведомлений.',
|
||||
'llm_personality': 'Говори спокойно, короткими фразами.',
|
||||
'allowed_command_prefixes': ['time.', 'weather.', 'lights.'],
|
||||
'disabled_command_prefixes': [],
|
||||
'greeting': 'Спокойной ночи.',
|
||||
'icon': '🌙',
|
||||
},
|
||||
{
|
||||
'name': 'driving',
|
||||
'description': 'Режим за рулём — только безопасные команды.',
|
||||
'llm_personality': 'Краткие безопасные ответы. Никаких длинных текстов.',
|
||||
'allowed_command_prefixes': [
|
||||
'music.', 'navigation.', 'calls.',
|
||||
'time.', 'weather.', 'reminders.',
|
||||
],
|
||||
'disabled_command_prefixes': ['windows.', 'screen.'],
|
||||
'greeting': 'Режим за рулём. Веди безопасно.',
|
||||
'icon': '🚗',
|
||||
},
|
||||
]
|
||||
os.makedirs(_PROFILES_DIR, exist_ok=True)
|
||||
for p in defaults:
|
||||
path = os.path.join(_PROFILES_DIR, f"{p['name']}.json")
|
||||
if not os.path.isfile(path):
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(p, f, ensure_ascii=False, indent=2)
|
||||
except OSError as exc:
|
||||
print(f"[profiles] seed {p['name']} failed: {exc}")
|
||||
|
||||
|
||||
def _default_profile() -> dict:
|
||||
return {
|
||||
'name': 'default',
|
||||
'description': 'fallback',
|
||||
'llm_personality': '',
|
||||
'allowed_command_prefixes': [],
|
||||
'disabled_command_prefixes': [],
|
||||
'greeting': '',
|
||||
'icon': '★',
|
||||
}
|
||||
|
||||
|
||||
def _load_profile(name: str) -> dict | None:
|
||||
path = os.path.join(_PROFILES_DIR, f'{name}.json')
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[profiles] load {name} failed: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _read_active_name() -> str:
|
||||
try:
|
||||
with open(_ACTIVE_FILE, encoding='utf-8') as f:
|
||||
return f.read().strip() or 'default'
|
||||
except OSError:
|
||||
return 'default'
|
||||
|
||||
|
||||
def _init():
|
||||
global _active_cache
|
||||
if _active_cache is not None:
|
||||
return
|
||||
_seed_defaults()
|
||||
name = _read_active_name()
|
||||
_active_cache = _load_profile(name) or _default_profile()
|
||||
|
||||
|
||||
def active() -> dict:
|
||||
_init()
|
||||
return dict(_active_cache or _default_profile())
|
||||
|
||||
|
||||
def active_name() -> str:
|
||||
return active().get('name', 'default')
|
||||
|
||||
|
||||
def set_active(name: str) -> dict:
|
||||
global _active_cache
|
||||
_init()
|
||||
profile = _load_profile(name)
|
||||
if not profile:
|
||||
raise ValueError(f"profile '{name}' not found")
|
||||
_active_cache = profile
|
||||
try:
|
||||
with open(_ACTIVE_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(name)
|
||||
except OSError as exc:
|
||||
print(f"[profiles] persist failed: {exc}")
|
||||
return dict(profile)
|
||||
|
||||
|
||||
def list_names() -> list[str]:
|
||||
_init()
|
||||
names = []
|
||||
if os.path.isdir(_PROFILES_DIR):
|
||||
for fname in os.listdir(_PROFILES_DIR):
|
||||
if fname.endswith('.json'):
|
||||
names.append(fname[:-5])
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def allows_command(cmd_id: str) -> bool:
|
||||
p = active()
|
||||
disabled = p.get('disabled_command_prefixes', []) or []
|
||||
if any(cmd_id.startswith(pfx) for pfx in disabled):
|
||||
return False
|
||||
allowed = p.get('allowed_command_prefixes', []) or []
|
||||
if not allowed:
|
||||
return True
|
||||
return any(cmd_id.startswith(pfx) for pfx in allowed)
|
||||
|
|
@ -27,6 +27,10 @@ sounddevice
|
|||
PyYAML
|
||||
python-dotenv
|
||||
|
||||
# Outlook COM automation (Win32-only). Optional: outlook_handlers degrades
|
||||
# gracefully when this isn't installed.
|
||||
pywin32>=305
|
||||
|
||||
# Semantic intent classifier (MiniLM-L6-v2 ONNX)
|
||||
onnxruntime>=1.17
|
||||
tokenizers>=0.15
|
||||
|
|
@ -38,3 +42,8 @@ noisereduce>=3.0
|
|||
ruamel.yaml
|
||||
pywebview
|
||||
pyautogui
|
||||
|
||||
# Flet — main jarvis-gui (mirrors the Rust Tauri GUI's 5 pages).
|
||||
# Optional: install only if you want the desktop GUI; the assistant runs
|
||||
# fine in console mode without it.
|
||||
flet>=0.85
|
||||
|
|
|
|||
13
run.bat
Normal file
13
run.bat
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
title J.A.R.V.I.S. (Python edition)
|
||||
|
||||
if exist ".venv\Scripts\python.exe" (
|
||||
".venv\Scripts\python.exe" main.py
|
||||
) else (
|
||||
python main.py
|
||||
)
|
||||
|
||||
echo.
|
||||
echo --- jarvis exited, press a key to close ---
|
||||
pause >nul
|
||||
307
scheduler_store.py
Normal file
307
scheduler_store.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"""Proactive scheduler — port of `crates/jarvis-core/src/scheduler.rs`.
|
||||
|
||||
JSON store at <here>/schedule.json. Background daemon thread ticks every 30s
|
||||
and fires due tasks via the speak callback.
|
||||
|
||||
Schedule formats (parsed by `parse_schedule`):
|
||||
"daily HH:MM"
|
||||
"at HH:MM"
|
||||
"every N minutes" / "every N hours" / "every N seconds"
|
||||
"in N minutes" / "in N hours" / "in N seconds"
|
||||
|
||||
Public API:
|
||||
init() # start the background thread, load state
|
||||
add(task_dict) -> str # returns id
|
||||
remove(id) -> bool
|
||||
list_all() -> list[dict]
|
||||
remove_by_text(query) -> int
|
||||
clear() -> int
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PATH = os.path.join(_HERE, 'schedule.json')
|
||||
_TICK_SECS = 30
|
||||
|
||||
_speak_fn = print
|
||||
_tasks: list[dict] = []
|
||||
_lock = threading.Lock()
|
||||
_thread_started = False
|
||||
|
||||
|
||||
def set_speak(fn):
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
|
||||
|
||||
def _speak(text):
|
||||
try:
|
||||
_speak_fn(text)
|
||||
except Exception as exc:
|
||||
print(f"[scheduler] speak: {exc}")
|
||||
|
||||
|
||||
# ── persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
def _load():
|
||||
global _tasks
|
||||
if os.path.isfile(_PATH):
|
||||
try:
|
||||
with open(_PATH, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_tasks = data.get('tasks', []) if isinstance(data, dict) else []
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[scheduler] corrupt: {exc}")
|
||||
_tasks = []
|
||||
|
||||
|
||||
def _save():
|
||||
tmp = _PATH + '.tmp'
|
||||
try:
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump({'tasks': _tasks}, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, _PATH)
|
||||
except OSError as exc:
|
||||
print(f"[scheduler] save: {exc}")
|
||||
|
||||
|
||||
# ── parsing ───────────────────────────────────────────────────────────────
|
||||
|
||||
_RU_UNITS = {
|
||||
'минут': 60, 'минуту': 60, 'минуты': 60,
|
||||
'час': 3600, 'часа': 3600, 'часов': 3600,
|
||||
'секунд': 1, 'секунду': 1, 'секунды': 1,
|
||||
}
|
||||
_EN_UNITS = {
|
||||
'minute': 60, 'minutes': 60,
|
||||
'hour': 3600, 'hours': 3600,
|
||||
'second': 1, 'seconds': 1,
|
||||
}
|
||||
|
||||
|
||||
def _unit_to_secs(token: str) -> int | None:
|
||||
"""Map a unit word to seconds. Tries Russian stems first."""
|
||||
low = token.lower()
|
||||
for stem, n in _RU_UNITS.items():
|
||||
if low.startswith(stem):
|
||||
return n
|
||||
for stem, n in _EN_UNITS.items():
|
||||
if low.startswith(stem):
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def parse_schedule(spec: str) -> dict:
|
||||
"""Returns a task-schedule dict, raises ValueError on bad input.
|
||||
|
||||
Schedule shape:
|
||||
{'kind': 'daily', 'hour': H, 'minute': M}
|
||||
{'kind': 'interval', 'seconds': S}
|
||||
{'kind': 'once', 'at': ts}
|
||||
"""
|
||||
s = spec.strip().lower()
|
||||
now = int(time.time())
|
||||
|
||||
# daily HH:MM
|
||||
m = re.match(r'^daily\s+(\d{1,2}):(\d{2})$', s)
|
||||
if m:
|
||||
h, mn = int(m.group(1)), int(m.group(2))
|
||||
if not (0 <= h <= 23 and 0 <= mn <= 59):
|
||||
raise ValueError("hour/minute out of range")
|
||||
return {'kind': 'daily', 'hour': h, 'minute': mn}
|
||||
|
||||
# at HH:MM
|
||||
m = re.match(r'^at\s+(\d{1,2}):(\d{2})$', s)
|
||||
if m:
|
||||
h, mn = int(m.group(1)), int(m.group(2))
|
||||
if not (0 <= h <= 23 and 0 <= mn <= 59):
|
||||
raise ValueError("hour/minute out of range")
|
||||
import datetime as dt
|
||||
target = dt.datetime.now().replace(hour=h, minute=mn, second=0, microsecond=0)
|
||||
if target.timestamp() <= now:
|
||||
target = target + dt.timedelta(days=1)
|
||||
return {'kind': 'once', 'at': int(target.timestamp())}
|
||||
|
||||
# every N <unit>
|
||||
m = re.match(r'^every\s+(\d+)\s+(\S+)$', s)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
secs_per = _unit_to_secs(m.group(2))
|
||||
if secs_per is None:
|
||||
raise ValueError(f"unknown unit: {m.group(2)}")
|
||||
return {'kind': 'interval', 'seconds': n * secs_per}
|
||||
|
||||
# in N <unit>
|
||||
m = re.match(r'^in\s+(\d+)\s+(\S+)$', s)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
secs_per = _unit_to_secs(m.group(2))
|
||||
if secs_per is None:
|
||||
raise ValueError(f"unknown unit: {m.group(2)}")
|
||||
return {'kind': 'once', 'at': now + n * secs_per}
|
||||
|
||||
raise ValueError(f"unknown schedule spec: {spec}")
|
||||
|
||||
|
||||
def _next_fire(schedule: dict, last_fired: int | None, now: int) -> int | None:
|
||||
kind = schedule.get('kind')
|
||||
if kind == 'once':
|
||||
if last_fired is not None:
|
||||
return None
|
||||
return schedule.get('at')
|
||||
if kind == 'interval':
|
||||
anchor = last_fired if last_fired is not None else now
|
||||
return anchor + int(schedule['seconds'])
|
||||
if kind == 'daily':
|
||||
import datetime as dt
|
||||
h, mn = int(schedule['hour']), int(schedule['minute'])
|
||||
today = dt.datetime.now().replace(hour=h, minute=mn, second=0, microsecond=0)
|
||||
today_ts = int(today.timestamp())
|
||||
if today_ts > now and (last_fired is None or
|
||||
dt.datetime.fromtimestamp(last_fired).date() != today.date()):
|
||||
return today_ts
|
||||
return today_ts + 86400
|
||||
return None
|
||||
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def _gen_id() -> str:
|
||||
import uuid as uuidlib
|
||||
return f"task-{uuidlib.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def add(task: dict) -> str:
|
||||
"""Add task. Required keys: name, schedule (dict or string), action.
|
||||
Action is a dict with at least {'type': 'speak', 'text': '...'}.
|
||||
"""
|
||||
sched = task.get('schedule')
|
||||
if isinstance(sched, str):
|
||||
sched = parse_schedule(sched)
|
||||
if not isinstance(sched, dict):
|
||||
raise ValueError("missing or invalid schedule")
|
||||
|
||||
tid = task.get('id') or _gen_id()
|
||||
record = {
|
||||
'id': tid,
|
||||
'name': task.get('name', 'task'),
|
||||
'schedule': sched,
|
||||
'action': task.get('action', {'type': 'speak', 'text': 'Напоминание.'}),
|
||||
'last_fired': None,
|
||||
'enabled': task.get('enabled', True),
|
||||
'created_at': int(time.time()),
|
||||
}
|
||||
with _lock:
|
||||
_tasks[:] = [t for t in _tasks if t['id'] != tid]
|
||||
_tasks.append(record)
|
||||
_save()
|
||||
return tid
|
||||
|
||||
|
||||
def remove(task_id: str) -> bool:
|
||||
with _lock:
|
||||
before = len(_tasks)
|
||||
_tasks[:] = [t for t in _tasks if t['id'] != task_id]
|
||||
if len(_tasks) != before:
|
||||
_save()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_all() -> list[dict]:
|
||||
with _lock:
|
||||
return [dict(t) for t in _tasks]
|
||||
|
||||
|
||||
def clear() -> int:
|
||||
with _lock:
|
||||
n = len(_tasks)
|
||||
_tasks.clear()
|
||||
_save()
|
||||
return n
|
||||
|
||||
|
||||
def remove_by_text(query: str) -> int:
|
||||
nq = query.strip().lower()
|
||||
if not nq:
|
||||
return 0
|
||||
with _lock:
|
||||
before = len(_tasks)
|
||||
def matches(t):
|
||||
if nq in t['name'].lower():
|
||||
return True
|
||||
action = t.get('action', {})
|
||||
if action.get('type') == 'speak':
|
||||
if nq in (action.get('text') or '').lower():
|
||||
return True
|
||||
return False
|
||||
_tasks[:] = [t for t in _tasks if not matches(t)]
|
||||
removed = before - len(_tasks)
|
||||
if removed:
|
||||
_save()
|
||||
return removed
|
||||
|
||||
|
||||
# ── background tick ────────────────────────────────────────────────────────
|
||||
|
||||
def _fire_action(action: dict) -> None:
|
||||
atype = action.get('type', 'speak')
|
||||
if atype == 'speak':
|
||||
_speak(action.get('text', '...'))
|
||||
else:
|
||||
print(f"[scheduler] unknown action type: {atype}")
|
||||
|
||||
|
||||
def _tick():
|
||||
now = int(time.time())
|
||||
fired_ids = []
|
||||
expired_once = []
|
||||
with _lock:
|
||||
for task in list(_tasks):
|
||||
if not task.get('enabled', True):
|
||||
continue
|
||||
nf = _next_fire(task['schedule'], task.get('last_fired'), now)
|
||||
if nf is not None and nf <= now:
|
||||
fired_ids.append(task['id'])
|
||||
if task['schedule'].get('kind') == 'once':
|
||||
expired_once.append(task['id'])
|
||||
|
||||
# Fire outside the lock — speak() can block.
|
||||
for task in list_all():
|
||||
if task['id'] in fired_ids:
|
||||
print(f"[scheduler] firing {task['id']} '{task['name']}'")
|
||||
_fire_action(task['action'])
|
||||
|
||||
if fired_ids:
|
||||
with _lock:
|
||||
for task in _tasks:
|
||||
if task['id'] in fired_ids:
|
||||
task['last_fired'] = now
|
||||
_tasks[:] = [t for t in _tasks if t['id'] not in expired_once]
|
||||
_save()
|
||||
|
||||
|
||||
def _tick_loop():
|
||||
while True:
|
||||
try:
|
||||
time.sleep(_TICK_SECS)
|
||||
_tick()
|
||||
except Exception as exc:
|
||||
print(f"[scheduler] tick err: {exc}")
|
||||
|
||||
|
||||
def init():
|
||||
"""Load tasks + start the background thread (idempotent)."""
|
||||
global _thread_started
|
||||
_load()
|
||||
if _thread_started:
|
||||
return
|
||||
_thread_started = True
|
||||
threading.Thread(target=_tick_loop, name='jarvis-scheduler',
|
||||
daemon=True).start()
|
||||
print(f"[scheduler] thread started ({len(_tasks)} tasks loaded)")
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
45
tests/conftest.py
Normal file
45
tests/conftest.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Pytest config: temp-dir isolation for state modules.
|
||||
|
||||
Each test gets a fresh dir for memory.json / schedule.json / etc so concurrent
|
||||
runs don't pollute the real user data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
import pytest
|
||||
|
||||
# Make parent package importable.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_state(tmp_path, monkeypatch):
|
||||
"""Patch state modules to use a tmp_path so we don't touch real state files."""
|
||||
|
||||
def patch_module(mod_name, path_attrs):
|
||||
"""Reimport a module and rewire its `_PATH` and friends to tmp_path."""
|
||||
if mod_name in sys.modules:
|
||||
del sys.modules[mod_name]
|
||||
mod = importlib.import_module(mod_name)
|
||||
for attr in path_attrs:
|
||||
if not hasattr(mod, attr):
|
||||
continue
|
||||
original = getattr(mod, attr)
|
||||
new_val = os.path.join(str(tmp_path), os.path.basename(original))
|
||||
monkeypatch.setattr(mod, attr, new_val)
|
||||
# Also reset any cached _loaded / _initialized flags
|
||||
for flag in ('_loaded', '_initialized', '_thread_started'):
|
||||
if hasattr(mod, flag):
|
||||
monkeypatch.setattr(mod, flag, False)
|
||||
# Reset stores
|
||||
for store_attr in ('_store', '_tasks'):
|
||||
if hasattr(mod, store_attr):
|
||||
cur = getattr(mod, store_attr)
|
||||
if isinstance(cur, dict):
|
||||
monkeypatch.setattr(mod, store_attr, {})
|
||||
elif isinstance(cur, list):
|
||||
monkeypatch.setattr(mod, store_attr, [])
|
||||
return mod
|
||||
|
||||
yield patch_module
|
||||
116
tests/test_expenses_handlers.py
Normal file
116
tests/test_expenses_handlers.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Tests for `expenses_handlers.py` — voice-driven personal finance tracker."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time as _time
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _capture(eh):
|
||||
captured = []
|
||||
eh.set_speak(captured.append)
|
||||
return captured
|
||||
|
||||
|
||||
def test_parse_expense_extracts_amount_and_category(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
assert eh.parse_expense("потратил 500 на еду") == (500.0, "еду")
|
||||
assert eh.parse_expense("spent 12.50 on coffee") == (12.5, "coffee")
|
||||
# Comma decimal (RU keyboard)
|
||||
assert eh.parse_expense("потратил 12,50 на транспорт") == (12.5, "транспорт")
|
||||
# No category → default
|
||||
amount, cat = eh.parse_expense("потратил 100")
|
||||
assert amount == 100.0
|
||||
assert cat == "разное"
|
||||
# Garbage
|
||||
assert eh.parse_expense("какая погода") is None
|
||||
assert eh.parse_expense("потратил bla") is None
|
||||
assert eh.parse_expense("потратил -100") is None
|
||||
assert eh.parse_expense("") is None
|
||||
|
||||
|
||||
def test_log_appends_entry(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
captured = _capture(eh)
|
||||
eh.do_expenses_log(voice="потратил 250 на кофе")
|
||||
data = eh.load()
|
||||
assert len(data['entries']) == 1
|
||||
e = data['entries'][0]
|
||||
assert e['amount'] == 250.0
|
||||
assert e['category'] == "кофе"
|
||||
assert "250" in captured[0]
|
||||
assert "кофе" in captured[0]
|
||||
|
||||
|
||||
def test_log_ignores_unparseable(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
captured = _capture(eh)
|
||||
eh.do_expenses_log(voice="ну блин")
|
||||
assert eh.load()['entries'] == []
|
||||
assert "не понял" in captured[0].lower()
|
||||
|
||||
|
||||
def test_today_sums_only_today(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
# Seed data: one entry today, one entry yesterday
|
||||
now = int(_time.time())
|
||||
eh._save({'entries': [
|
||||
{'ts': now, 'amount': 100.0, 'category': 'еда'},
|
||||
{'ts': now - 86400, 'amount': 999.0, 'category': 'старое'},
|
||||
]})
|
||||
captured = _capture(eh)
|
||||
eh.do_expenses_today()
|
||||
assert "100" in captured[0]
|
||||
assert "999" not in captured[0]
|
||||
|
||||
|
||||
def test_week_sums_last_7_days(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
now = int(_time.time())
|
||||
eh._save({'entries': [
|
||||
{'ts': now - 3 * 86400, 'amount': 100.0, 'category': 'a'},
|
||||
{'ts': now - 5 * 86400, 'amount': 200.0, 'category': 'b'},
|
||||
{'ts': now - 10 * 86400, 'amount': 999.0, 'category': 'старое'},
|
||||
]})
|
||||
captured = _capture(eh)
|
||||
eh.do_expenses_week()
|
||||
# 100 + 200 = 300 in last 7 days; 999 excluded
|
||||
assert "300" in captured[0]
|
||||
assert "999" not in captured[0]
|
||||
assert "2 трат" in captured[0]
|
||||
|
||||
|
||||
def test_breakdown_sorts_by_amount(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
now = int(_time.time())
|
||||
eh._save({'entries': [
|
||||
{'ts': now, 'amount': 100, 'category': 'small'},
|
||||
{'ts': now, 'amount': 500, 'category': 'big'},
|
||||
{'ts': now, 'amount': 200, 'category': 'medium'},
|
||||
]})
|
||||
captured = _capture(eh)
|
||||
eh.do_expenses_breakdown()
|
||||
text = captured[0]
|
||||
# big should appear before medium before small
|
||||
assert text.index("big") < text.index("medium") < text.index("small")
|
||||
|
||||
|
||||
def test_breakdown_collapses_tail(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
now = int(_time.time())
|
||||
eh._save({'entries': [
|
||||
{'ts': now, 'amount': float(i + 1) * 10, 'category': f'c{i}'}
|
||||
for i in range(8) # 8 categories — top 5 spoken, 3 collapse
|
||||
]})
|
||||
captured = _capture(eh)
|
||||
eh.do_expenses_breakdown()
|
||||
assert "и другое" in captured[0]
|
||||
|
||||
|
||||
def test_today_when_empty_says_so(isolated_state):
|
||||
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||
captured = _capture(eh)
|
||||
eh.do_expenses_today()
|
||||
assert "ничего" in captured[0].lower()
|
||||
49
tests/test_gui_smoke.py
Normal file
49
tests/test_gui_smoke.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Smoke tests for the Flet GUI module — verifies that imports work and that
|
||||
each page builder is callable. We do NOT launch a real Flet window; we mock
|
||||
the `flet.Page` object enough that the builders execute without errors.
|
||||
|
||||
If Flet isn't installed, these tests skip cleanly so the suite still passes
|
||||
on console-only installs.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
flet = pytest.importorskip("flet")
|
||||
|
||||
|
||||
def test_gui_app_module_imports():
|
||||
from gui import app # pylint: disable=import-outside-toplevel
|
||||
# The module must expose the canonical 7 pages.
|
||||
titles = [t for t, _ in app.PAGES]
|
||||
assert "Главная" in titles
|
||||
assert "Команды" in titles
|
||||
assert "Макросы" in titles
|
||||
assert "Расписание" in titles
|
||||
assert "Память" in titles
|
||||
assert "Плагины" in titles
|
||||
assert "Настройки" in titles
|
||||
|
||||
|
||||
def test_services_module_imports_cleanly():
|
||||
# services pulls in memory_store, profiles_store, macros_store, scheduler_store
|
||||
# and llm_backend. None of those should fail to import in a fresh process.
|
||||
from gui import services # pylint: disable=import-outside-toplevel
|
||||
# Just verify the public surface exists.
|
||||
for fn in (
|
||||
"memory_all", "memory_remember", "memory_forget", "memory_search",
|
||||
"profile_list", "profile_active", "profile_set",
|
||||
"macros_all", "macros_delete", "macros_recording",
|
||||
"scheduler_all", "scheduler_remove",
|
||||
"plugins_all", "plugins_open_folder", "plugins_set_enabled",
|
||||
"llm_active", "llm_swap",
|
||||
"assistant_run", "assistant_is_running",
|
||||
):
|
||||
assert callable(getattr(services, fn)), f"missing or non-callable: {fn}"
|
||||
|
||||
|
||||
def test_each_page_module_exposes_build_callable():
|
||||
from gui.pages import home, commands, macros, scheduler, memory, plugins, settings
|
||||
for mod in (home, commands, macros, scheduler, memory, plugins, settings):
|
||||
assert callable(getattr(mod, "build", None)), \
|
||||
f"{mod.__name__}.build missing"
|
||||
68
tests/test_llm_backend.py
Normal file
68
tests/test_llm_backend.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Tests for llm_backend.parse_backend + state machine."""
|
||||
|
||||
import os
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def test_parse_backend_groq_aliases():
|
||||
if 'llm_backend' in sys.modules:
|
||||
del sys.modules['llm_backend']
|
||||
m = importlib.import_module('llm_backend')
|
||||
for alias in ('groq', 'GROQ', 'cloud', 'облако', 'клауд', ' Groq '):
|
||||
assert m.parse_backend(alias) == 'groq', f"failed: {alias!r}"
|
||||
|
||||
|
||||
def test_parse_backend_ollama_aliases():
|
||||
if 'llm_backend' in sys.modules:
|
||||
del sys.modules['llm_backend']
|
||||
m = importlib.import_module('llm_backend')
|
||||
for alias in ('ollama', 'OLLAMA', 'local', 'локальный', 'локал', 'локальн'):
|
||||
assert m.parse_backend(alias) == 'ollama', f"failed: {alias!r}"
|
||||
|
||||
|
||||
def test_parse_backend_unknown_returns_none():
|
||||
if 'llm_backend' in sys.modules:
|
||||
del sys.modules['llm_backend']
|
||||
m = importlib.import_module('llm_backend')
|
||||
assert m.parse_backend('openai') is None
|
||||
assert m.parse_backend('claude') is None
|
||||
assert m.parse_backend('') is None
|
||||
assert m.parse_backend(None) is None
|
||||
|
||||
|
||||
def test_persist_round_trip(tmp_path, monkeypatch):
|
||||
if 'llm_backend' in sys.modules:
|
||||
del sys.modules['llm_backend']
|
||||
m = importlib.import_module('llm_backend')
|
||||
monkeypatch.setattr(m, '_CHOICE_FILE', str(tmp_path / 'llm_backend.txt'))
|
||||
m._persist('ollama')
|
||||
assert m._read_persisted() == 'ollama'
|
||||
m._persist('groq')
|
||||
assert m._read_persisted() == 'groq'
|
||||
|
||||
|
||||
def test_auto_detect_fallback_to_ollama(tmp_path, monkeypatch):
|
||||
"""When no GROQ_TOKEN in config, auto-detect should pick ollama."""
|
||||
if 'llm_backend' in sys.modules:
|
||||
del sys.modules['llm_backend']
|
||||
m = importlib.import_module('llm_backend')
|
||||
|
||||
# Mock config module with no GROQ_TOKEN
|
||||
class FakeConfig:
|
||||
GROQ_TOKEN = ''
|
||||
monkeypatch.setitem(sys.modules, 'config', FakeConfig())
|
||||
|
||||
assert m._auto_detect_backend() == 'ollama'
|
||||
|
||||
|
||||
def test_auto_detect_picks_groq_when_token_present(tmp_path, monkeypatch):
|
||||
if 'llm_backend' in sys.modules:
|
||||
del sys.modules['llm_backend']
|
||||
m = importlib.import_module('llm_backend')
|
||||
|
||||
class FakeConfig:
|
||||
GROQ_TOKEN = 'gsk_fake_test_token'
|
||||
monkeypatch.setitem(sys.modules, 'config', FakeConfig())
|
||||
|
||||
assert m._auto_detect_backend() == 'groq'
|
||||
87
tests/test_macros_store.py
Normal file
87
tests/test_macros_store.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Tests for macros_store: record/save/cancel/list/delete + control-phrase filter."""
|
||||
|
||||
|
||||
def test_is_macro_control_filters_record_phrases(isolated_state):
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
assert m._is_control("Запиши макрос работа")
|
||||
assert m._is_control("сохрани макрос")
|
||||
assert m._is_control("запусти макрос работа")
|
||||
assert m._is_control("Удали макрос работа")
|
||||
# Regular commands NOT filtered
|
||||
assert not m._is_control("открой браузер")
|
||||
assert not m._is_control("установи громкость 50")
|
||||
|
||||
|
||||
def test_start_save_round_trip(isolated_state):
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
m.start("test")
|
||||
assert m.is_recording()
|
||||
assert m.recording_name() == 'test'
|
||||
|
||||
m.record_step("открой браузер")
|
||||
m.record_step("режим работа")
|
||||
m.record_step("установи громкость 30")
|
||||
# Control phrase should be filtered
|
||||
m.record_step("запиши макрос новый")
|
||||
|
||||
count = m.save()
|
||||
assert count == 3 # 3 + 1 filtered = 3
|
||||
assert not m.is_recording()
|
||||
|
||||
mac = m.get('test')
|
||||
assert mac is not None
|
||||
assert mac['steps'] == [
|
||||
'открой браузер',
|
||||
'режим работа',
|
||||
'установи громкость 30',
|
||||
]
|
||||
|
||||
|
||||
def test_save_without_recording_raises(isolated_state):
|
||||
import pytest
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
with pytest.raises(RuntimeError):
|
||||
m.save()
|
||||
|
||||
|
||||
def test_save_empty_recording_raises(isolated_state):
|
||||
import pytest
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
m.start("empty")
|
||||
with pytest.raises(RuntimeError):
|
||||
m.save()
|
||||
|
||||
|
||||
def test_cancel_aborts_without_saving(isolated_state):
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
m.start("test")
|
||||
m.record_step("foo")
|
||||
assert m.cancel() is True
|
||||
assert not m.is_recording()
|
||||
assert m.get('test') is None
|
||||
|
||||
|
||||
def test_delete(isolated_state):
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
m.start("test")
|
||||
m.record_step("x")
|
||||
m.save()
|
||||
assert m.delete("test") is True
|
||||
assert m.get('test') is None
|
||||
assert m.delete("test") is False # idempotent
|
||||
|
||||
|
||||
def test_list_names_sorted(isolated_state):
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
for name in ('zeta', 'alpha', 'beta'):
|
||||
m.start(name)
|
||||
m.record_step("step")
|
||||
m.save()
|
||||
assert m.list_names() == ['alpha', 'beta', 'zeta']
|
||||
|
||||
|
||||
def test_replay_unknown_raises(isolated_state):
|
||||
import pytest
|
||||
m = isolated_state('macros_store', ['_PATH'])
|
||||
with pytest.raises(KeyError):
|
||||
m.replay("nonexistent", lambda s: None)
|
||||
79
tests/test_memory_store.py
Normal file
79
tests/test_memory_store.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Tests for memory_store — port of long_term_memory tests in rust."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
def test_remember_recall_round_trip(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
m.remember("любимый чай", "улун")
|
||||
assert m.recall("любимый чай") == "улун"
|
||||
assert m.recall("Любимый Чай") == "улун" # case-insensitive
|
||||
|
||||
|
||||
def test_recall_returns_none_for_missing(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
assert m.recall("nonexistent") is None
|
||||
|
||||
|
||||
def test_forget_removes(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
m.remember("foo", "bar")
|
||||
assert m.forget("foo") is True
|
||||
assert m.recall("foo") is None
|
||||
assert m.forget("foo") is False # idempotent
|
||||
|
||||
|
||||
def test_search_by_key_and_value(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
m.remember("любимый чай", "улун")
|
||||
m.remember("любимый кофе", "арабика")
|
||||
m.remember("питомец", "собака бася")
|
||||
|
||||
hits = m.search("чай", 5)
|
||||
assert len(hits) == 1
|
||||
assert hits[0]['key'] == "любимый чай"
|
||||
|
||||
hits = m.search("бася", 5)
|
||||
assert len(hits) == 1
|
||||
assert hits[0]['key'] == "питомец"
|
||||
|
||||
|
||||
def test_search_respects_limit(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
for i in range(10):
|
||||
m.remember(f"key{i}", "value")
|
||||
hits = m.search("value", 3)
|
||||
assert len(hits) == 3
|
||||
|
||||
|
||||
def test_search_empty_query_returns_nothing(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
m.remember("foo", "bar")
|
||||
assert m.search("", 5) == []
|
||||
assert m.search(" ", 5) == []
|
||||
|
||||
|
||||
def test_persists_across_module_reload(isolated_state, tmp_path):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
m.remember("persistent", "yes")
|
||||
json_file = m._PATH
|
||||
assert os.path.isfile(json_file)
|
||||
with open(json_file, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
assert data['entries']['persistent']['value'] == 'yes'
|
||||
|
||||
|
||||
def test_build_llm_context_format(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
m.remember("любимый чай", "улун")
|
||||
# Query "чай" is substring of key "любимый чай" → matches.
|
||||
ctx = m.build_llm_context("чай", 5)
|
||||
assert "любимый чай = улун" in ctx
|
||||
assert ctx.startswith("Известные факты")
|
||||
|
||||
|
||||
def test_build_llm_context_empty(isolated_state):
|
||||
m = isolated_state('memory_store', ['_PATH'])
|
||||
assert m.build_llm_context("anything", 5) == ""
|
||||
103
tests/test_outlook_handlers.py
Normal file
103
tests/test_outlook_handlers.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Tests for outlook_handlers — COM bridge mocked via unittest.mock.
|
||||
|
||||
We patch the module-level `_outlook_session` to return a fake (app, ns) pair
|
||||
made of MagicMocks so we never need a live Outlook install.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _fresh_module():
|
||||
if 'outlook_handlers' in sys.modules:
|
||||
del sys.modules['outlook_handlers']
|
||||
return importlib.import_module('outlook_handlers')
|
||||
|
||||
|
||||
def _make_inbox_with_unread(count):
|
||||
"""Build a fake Inbox object whose UnReadItemCount is `count`."""
|
||||
inbox = MagicMock()
|
||||
inbox.UnReadItemCount = count
|
||||
return inbox
|
||||
|
||||
|
||||
def test_unread_count_speaks_singular_for_one():
|
||||
mod = _fresh_module()
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
|
||||
app = MagicMock()
|
||||
ns = MagicMock()
|
||||
ns.GetDefaultFolder.return_value = _make_inbox_with_unread(1)
|
||||
with patch.object(mod, '_outlook_session', return_value=(app, ns)):
|
||||
mod.do_outlook_unread_count({}, '')
|
||||
|
||||
assert spoken, "expected the handler to speak"
|
||||
assert "Одно" in spoken[0]
|
||||
|
||||
|
||||
def test_unread_count_zero_says_no_unread():
|
||||
mod = _fresh_module()
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
|
||||
app = MagicMock()
|
||||
ns = MagicMock()
|
||||
ns.GetDefaultFolder.return_value = _make_inbox_with_unread(0)
|
||||
with patch.object(mod, '_outlook_session', return_value=(app, ns)):
|
||||
mod.do_outlook_unread_count({}, '')
|
||||
|
||||
assert spoken
|
||||
assert "нет" in spoken[0].lower()
|
||||
|
||||
|
||||
def test_unread_count_plural_count():
|
||||
mod = _fresh_module()
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
|
||||
ns = MagicMock()
|
||||
ns.GetDefaultFolder.return_value = _make_inbox_with_unread(7)
|
||||
with patch.object(mod, '_outlook_session', return_value=(MagicMock(), ns)):
|
||||
mod.do_outlook_unread_count({}, '')
|
||||
|
||||
assert "7" in spoken[0]
|
||||
|
||||
|
||||
def test_unavailable_outlook_says_so():
|
||||
mod = _fresh_module()
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
|
||||
with patch.object(mod, '_outlook_session', return_value=(None, None)):
|
||||
mod.do_outlook_unread_count({}, '')
|
||||
|
||||
assert spoken
|
||||
assert "Outlook" in spoken[0]
|
||||
|
||||
|
||||
def test_extract_recipient_strips_trigger():
|
||||
mod = _fresh_module()
|
||||
assert mod._extract_recipient("отправь письмо Дмитрию") == "Дмитрию"
|
||||
assert mod._extract_recipient("email this to Bob") == "Bob"
|
||||
assert mod._extract_recipient("Bob") == "Bob"
|
||||
assert mod._extract_recipient("") == ""
|
||||
|
||||
|
||||
def test_send_clipboard_no_recipient_prompts_for_name():
|
||||
mod = _fresh_module()
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
|
||||
# No trigger and no recipient in the voice -> _extract_recipient returns ""
|
||||
mod.do_outlook_send_clipboard({}, "")
|
||||
|
||||
assert spoken
|
||||
assert "имя" in spoken[0].lower() or "name" in spoken[0].lower()
|
||||
|
||||
|
||||
def test_strip_html_basic():
|
||||
mod = _fresh_module()
|
||||
assert mod._strip_html("<b>Hi</b> there") == "Hi there"
|
||||
assert mod._strip_html("") == ""
|
||||
62
tests/test_personality_handlers.py
Normal file
62
tests/test_personality_handlers.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Tests for personality_handlers — picks vary, speak is invoked."""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def _fresh_module(monkeypatch):
|
||||
if 'personality_handlers' in sys.modules:
|
||||
del sys.modules['personality_handlers']
|
||||
return importlib.import_module('personality_handlers')
|
||||
|
||||
|
||||
def test_greet_speaks_something(monkeypatch):
|
||||
mod = _fresh_module(monkeypatch)
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
mod.do_personality_greet({}, '')
|
||||
assert len(spoken) == 1
|
||||
assert isinstance(spoken[0], str)
|
||||
assert len(spoken[0]) > 0
|
||||
|
||||
|
||||
def test_greet_bucket_thresholds(monkeypatch):
|
||||
mod = _fresh_module(monkeypatch)
|
||||
assert mod._greet_bucket(7) is mod._GREET_MORNING
|
||||
assert mod._greet_bucket(13) is mod._GREET_MIDDAY
|
||||
assert mod._greet_bucket(19) is mod._GREET_EVENING
|
||||
assert mod._greet_bucket(2) is mod._GREET_NIGHT
|
||||
assert mod._greet_bucket(23) is mod._GREET_NIGHT
|
||||
|
||||
|
||||
def test_thanks_returns_from_pool(monkeypatch):
|
||||
mod = _fresh_module(monkeypatch)
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
mod.do_personality_thanks({}, '')
|
||||
assert spoken[0] in mod._THANKS
|
||||
|
||||
|
||||
def test_tony_quote_returns_from_pool(monkeypatch):
|
||||
mod = _fresh_module(monkeypatch)
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
mod.do_personality_tony_quote({}, '')
|
||||
assert spoken[0] in mod._TONY_QUOTES
|
||||
|
||||
|
||||
def test_compliment_returns_from_pool(monkeypatch):
|
||||
mod = _fresh_module(monkeypatch)
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
mod.do_personality_compliment({}, '')
|
||||
assert spoken[0] in mod._COMPLIMENT
|
||||
|
||||
|
||||
def test_how_are_you_speaks(monkeypatch):
|
||||
mod = _fresh_module(monkeypatch)
|
||||
spoken = []
|
||||
mod.set_speak(lambda t: spoken.append(t))
|
||||
mod.do_personality_how_are_you({}, '')
|
||||
assert len(spoken) == 1
|
||||
assert len(spoken[0]) > 0
|
||||
96
tests/test_plugins_store.py
Normal file
96
tests/test_plugins_store.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Tests for `plugins_store.py` — mirrors the Rust `plugins.rs` tests."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
import plugins_store
|
||||
|
||||
|
||||
def write(path, content):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wt", encoding="utf8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
GOOD_YAML = """
|
||||
my_plugin_hello:
|
||||
phrases:
|
||||
- привет плагин
|
||||
- тест плагин
|
||||
action: {type: shell, cmd: 'start "" notepad.exe'}
|
||||
"""
|
||||
|
||||
BAD_YAML = "this is :: not valid yaml ::\n ::: -"
|
||||
|
||||
|
||||
def test_discover_empty_dir_returns_empty(tmp_path):
|
||||
assert plugins_store.discover_in(str(tmp_path)) == {}
|
||||
|
||||
|
||||
def test_discover_loads_yaml(tmp_path):
|
||||
write(str(tmp_path / "greeter" / "commands.yaml"), GOOD_YAML)
|
||||
out = plugins_store.discover_in(str(tmp_path))
|
||||
assert "my_plugin_hello" in out
|
||||
assert out["my_plugin_hello"]["action"]["type"] == "shell"
|
||||
|
||||
|
||||
def test_disabled_flag_skips_pack(tmp_path):
|
||||
write(str(tmp_path / "muted" / "commands.yaml"), GOOD_YAML)
|
||||
write(str(tmp_path / "muted" / plugins_store.DISABLED_FLAG), "")
|
||||
out = plugins_store.discover_in(str(tmp_path))
|
||||
assert out == {}
|
||||
|
||||
|
||||
def test_malformed_yaml_does_not_break_other_packs(tmp_path):
|
||||
write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML)
|
||||
write(str(tmp_path / "good" / "commands.yaml"), GOOD_YAML)
|
||||
out = plugins_store.discover_in(str(tmp_path))
|
||||
assert "my_plugin_hello" in out
|
||||
# bad pack contributed nothing
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_duplicate_ids_across_packs_first_wins(tmp_path):
|
||||
write(str(tmp_path / "pack_a" / "commands.yaml"), GOOD_YAML)
|
||||
write(
|
||||
str(tmp_path / "pack_b" / "commands.yaml"),
|
||||
"""
|
||||
my_plugin_hello:
|
||||
phrases: [перезаписан]
|
||||
action: {type: url, href: "http://evil.com"}
|
||||
""",
|
||||
)
|
||||
out = plugins_store.discover_in(str(tmp_path))
|
||||
# alphabetical order means pack_a wins
|
||||
assert out["my_plugin_hello"]["action"]["type"] == "shell"
|
||||
|
||||
|
||||
def test_non_dirs_ignored(tmp_path):
|
||||
write(str(tmp_path / "README.md"), "# not a pack")
|
||||
out = plugins_store.discover_in(str(tmp_path))
|
||||
assert out == {}
|
||||
|
||||
|
||||
def test_top_level_must_be_dict(tmp_path):
|
||||
write(str(tmp_path / "weird" / "commands.yaml"), "- just\n- a\n- list\n")
|
||||
out = plugins_store.discover_in(str(tmp_path))
|
||||
assert out == {}
|
||||
|
||||
|
||||
def test_list_packs_in_reports_both_states(tmp_path):
|
||||
write(str(tmp_path / "alpha" / "commands.yaml"), GOOD_YAML)
|
||||
write(str(tmp_path / "bravo" / "commands.yaml"), GOOD_YAML)
|
||||
write(str(tmp_path / "bravo" / plugins_store.DISABLED_FLAG), "")
|
||||
listing = plugins_store.list_packs_in(str(tmp_path))
|
||||
by_name = {p["name"]: p for p in listing}
|
||||
assert by_name["alpha"]["enabled"] is True
|
||||
assert by_name["bravo"]["enabled"] is False
|
||||
assert by_name["alpha"]["command_count"] == 1
|
||||
|
||||
|
||||
def test_list_packs_reports_yaml_errors(tmp_path):
|
||||
write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML)
|
||||
listing = plugins_store.list_packs_in(str(tmp_path))
|
||||
assert len(listing) == 1
|
||||
assert listing[0]["error"] is not None
|
||||
assert listing[0]["command_count"] == 0
|
||||
61
tests/test_profiles_store.py
Normal file
61
tests/test_profiles_store.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Tests for profiles_store."""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
|
||||
def test_default_seeded_on_init(isolated_state, tmp_path):
|
||||
p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE'])
|
||||
p._init()
|
||||
names = p.list_names()
|
||||
# 5 defaults: default, work, game, sleep, driving
|
||||
for expected in ('default', 'work', 'game', 'sleep', 'driving'):
|
||||
assert expected in names, f"missing seeded profile: {expected}"
|
||||
|
||||
|
||||
def test_active_defaults_to_default(isolated_state):
|
||||
p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE'])
|
||||
a = p.active()
|
||||
assert a['name'] == 'default'
|
||||
|
||||
|
||||
def test_set_active_persists(isolated_state):
|
||||
p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE'])
|
||||
p.set_active('work')
|
||||
assert p.active_name() == 'work'
|
||||
assert os.path.isfile(p._ACTIVE_FILE)
|
||||
with open(p._ACTIVE_FILE, encoding='utf-8') as f:
|
||||
assert f.read().strip() == 'work'
|
||||
|
||||
|
||||
def test_set_active_unknown_raises(isolated_state):
|
||||
p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE'])
|
||||
try:
|
||||
p.set_active('nonexistent')
|
||||
assert False, "should have raised"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_allows_command_default_allows_all(isolated_state):
|
||||
p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE'])
|
||||
p.set_active('default')
|
||||
assert p.allows_command('anything')
|
||||
assert p.allows_command('games.steam.launch')
|
||||
|
||||
|
||||
def test_allows_command_work_disables_games(isolated_state):
|
||||
p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE'])
|
||||
p.set_active('work')
|
||||
assert not p.allows_command('games.steam.launch')
|
||||
assert not p.allows_command('fun.joke')
|
||||
assert p.allows_command('time.current')
|
||||
|
||||
|
||||
def test_allows_command_driving_whitelist(isolated_state):
|
||||
p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE'])
|
||||
p.set_active('driving')
|
||||
assert p.allows_command('music.play')
|
||||
assert p.allows_command('weather.now')
|
||||
assert not p.allows_command('windows.minimize')
|
||||
assert not p.allows_command('screen.brightness')
|
||||
109
tests/test_scheduler_store.py
Normal file
109
tests/test_scheduler_store.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Tests for scheduler_store: parser + add/remove + serde."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
|
||||
|
||||
def test_parse_daily(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
schedule = s.parse_schedule("daily 09:00")
|
||||
assert schedule == {'kind': 'daily', 'hour': 9, 'minute': 0}
|
||||
|
||||
|
||||
def test_parse_at_today_or_tomorrow(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
schedule = s.parse_schedule("at 23:59")
|
||||
assert schedule['kind'] == 'once'
|
||||
assert schedule['at'] > time.time()
|
||||
|
||||
|
||||
def test_parse_every_minutes(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
schedule = s.parse_schedule("every 30 minutes")
|
||||
assert schedule == {'kind': 'interval', 'seconds': 1800}
|
||||
|
||||
|
||||
def test_parse_every_russian(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
schedule = s.parse_schedule("every 2 часа")
|
||||
assert schedule == {'kind': 'interval', 'seconds': 7200}
|
||||
|
||||
|
||||
def test_parse_in_minutes_is_once(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
schedule = s.parse_schedule("in 5 minutes")
|
||||
assert schedule['kind'] == 'once'
|
||||
# Should be roughly 300 seconds in the future
|
||||
delta = schedule['at'] - time.time()
|
||||
assert 290 <= delta <= 310
|
||||
|
||||
|
||||
def test_parse_bad_unit_raises(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
with pytest.raises(ValueError):
|
||||
s.parse_schedule("every 5 weeks")
|
||||
|
||||
|
||||
def test_parse_bad_time_raises(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
with pytest.raises(ValueError):
|
||||
s.parse_schedule("daily 25:00")
|
||||
with pytest.raises(ValueError):
|
||||
s.parse_schedule("at 12:99")
|
||||
|
||||
|
||||
def test_add_remove(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
tid = s.add({
|
||||
'name': 'Test',
|
||||
'schedule': 'in 1 hour',
|
||||
'action': {'type': 'speak', 'text': 'hello'},
|
||||
})
|
||||
assert tid
|
||||
tasks = s.list_all()
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]['name'] == 'Test'
|
||||
|
||||
assert s.remove(tid) is True
|
||||
assert s.list_all() == []
|
||||
assert s.remove(tid) is False # idempotent
|
||||
|
||||
|
||||
def test_remove_by_text(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
s.add({
|
||||
'name': 'Water',
|
||||
'schedule': 'every 2 hours',
|
||||
'action': {'type': 'speak', 'text': 'Попей воды.'},
|
||||
})
|
||||
s.add({
|
||||
'name': 'Stretch',
|
||||
'schedule': 'every 50 minutes',
|
||||
'action': {'type': 'speak', 'text': 'Размяться.'},
|
||||
})
|
||||
|
||||
removed = s.remove_by_text("воды")
|
||||
assert removed == 1
|
||||
assert len(s.list_all()) == 1
|
||||
|
||||
|
||||
def test_clear(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
s.add({'name': 'a', 'schedule': 'in 1 hour', 'action': {'type': 'speak', 'text': 'x'}})
|
||||
s.add({'name': 'b', 'schedule': 'in 2 hours', 'action': {'type': 'speak', 'text': 'y'}})
|
||||
assert s.clear() == 2
|
||||
assert s.list_all() == []
|
||||
|
||||
|
||||
def test_next_fire_once(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
schedule = {'kind': 'once', 'at': 500}
|
||||
assert s._next_fire(schedule, None, 1000) == 500
|
||||
# Already fired
|
||||
assert s._next_fire(schedule, 500, 1000) is None
|
||||
|
||||
|
||||
def test_next_fire_interval(isolated_state):
|
||||
s = isolated_state('scheduler_store', ['_PATH'])
|
||||
schedule = {'kind': 'interval', 'seconds': 60}
|
||||
assert s._next_fire(schedule, 100, 1000) == 160
|
||||
179
tests/test_time_tracker.py
Normal file
179
tests/test_time_tracker.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Tests for time_tracker_handlers — parity with rust time_tracker pack."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time as _time
|
||||
import pytest
|
||||
|
||||
|
||||
def test_load_returns_empty_when_file_missing(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
data = tt.load()
|
||||
assert data == {'current_session_start': None, 'sessions': []}
|
||||
|
||||
|
||||
def test_start_session_records_timestamp(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
tt.start_session(now=1_700_000_000)
|
||||
data = tt.load()
|
||||
assert data['current_session_start'] == 1_700_000_000
|
||||
assert data['sessions'] == []
|
||||
|
||||
|
||||
def test_start_session_is_idempotent_when_already_running(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
tt.start_session(now=1_700_000_000)
|
||||
tt.start_session(now=1_700_010_000) # second start should not overwrite
|
||||
data = tt.load()
|
||||
assert data['current_session_start'] == 1_700_000_000
|
||||
|
||||
|
||||
def test_stop_session_pushes_completed(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
tt.start_session(now=1_700_000_000)
|
||||
_, elapsed = tt.stop_session(now=1_700_003_600) # +1 hour
|
||||
assert elapsed == 3600
|
||||
data = tt.load()
|
||||
assert data['current_session_start'] is None
|
||||
assert len(data['sessions']) == 1
|
||||
assert data['sessions'][0]['start'] == 1_700_000_000
|
||||
assert data['sessions'][0]['end'] == 1_700_003_600
|
||||
|
||||
|
||||
def test_stop_session_without_open_returns_none(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
_, elapsed = tt.stop_session(now=1_700_000_000)
|
||||
assert elapsed is None
|
||||
|
||||
|
||||
def test_today_total_sums_overlap_in_local_day(isolated_state):
|
||||
"""Two sessions today + one yesterday — only today's pair is counted."""
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
|
||||
# Pick a deterministic "now" at 15:00 local time today.
|
||||
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
|
||||
now = today_midnight + 15 * 3600 # 15:00 local today
|
||||
|
||||
data = {
|
||||
'current_session_start': None,
|
||||
'sessions': [
|
||||
# yesterday: 10:00 → 12:00 (2h, ignored)
|
||||
{'start': today_midnight - 14 * 3600, 'end': today_midnight - 12 * 3600},
|
||||
# today: 09:00 → 10:30 (90 min)
|
||||
{'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600 + 1800},
|
||||
# today: 13:00 → 14:00 (60 min)
|
||||
{'start': today_midnight + 13 * 3600, 'end': today_midnight + 14 * 3600},
|
||||
],
|
||||
}
|
||||
tt.save(data)
|
||||
|
||||
total = tt.today_total(now=now)
|
||||
assert total == 90 * 60 + 60 * 60 # 2h30m
|
||||
|
||||
|
||||
def test_today_total_includes_open_session(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
|
||||
now = today_midnight + 10 * 3600 # 10:00
|
||||
data = {
|
||||
'current_session_start': today_midnight + 9 * 3600, # opened at 09:00
|
||||
'sessions': [],
|
||||
}
|
||||
tt.save(data)
|
||||
total = tt.today_total(now=now)
|
||||
assert total == 3600 # 1h of open session counts
|
||||
|
||||
|
||||
def test_week_total_covers_last_seven_days(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
|
||||
now = today_midnight + 12 * 3600
|
||||
|
||||
data = {
|
||||
'current_session_start': None,
|
||||
'sessions': [
|
||||
# 8 days ago: outside window
|
||||
{'start': today_midnight - 8 * 86400, 'end': today_midnight - 8 * 86400 + 3600},
|
||||
# 3 days ago: inside window (1h)
|
||||
{'start': today_midnight - 3 * 86400, 'end': today_midnight - 3 * 86400 + 3600},
|
||||
# today: 2h
|
||||
{'start': today_midnight + 9 * 3600, 'end': today_midnight + 11 * 3600},
|
||||
],
|
||||
}
|
||||
tt.save(data)
|
||||
|
||||
total = tt.week_total(now=now)
|
||||
assert total == 3 * 3600 # 3 hours
|
||||
|
||||
|
||||
def test_reset_today_drops_only_todays_sessions(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d')))
|
||||
now = today_midnight + 14 * 3600
|
||||
data = {
|
||||
'current_session_start': today_midnight + 13 * 3600,
|
||||
'sessions': [
|
||||
{'start': today_midnight - 86400, 'end': today_midnight - 86400 + 3600}, # yesterday
|
||||
{'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600}, # today
|
||||
{'start': today_midnight + 11 * 3600, 'end': today_midnight + 12 * 3600}, # today
|
||||
],
|
||||
}
|
||||
tt.save(data)
|
||||
|
||||
dropped, had_open = tt.reset_today(now=now)
|
||||
assert dropped == 2
|
||||
assert had_open is True
|
||||
|
||||
after = tt.load()
|
||||
assert after['current_session_start'] is None
|
||||
assert len(after['sessions']) == 1
|
||||
assert after['sessions'][0]['start'] == today_midnight - 86400
|
||||
|
||||
|
||||
def test_reset_today_when_empty_reports_zero(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
dropped, had_open = tt.reset_today(now=1_700_000_000)
|
||||
assert dropped == 0
|
||||
assert had_open is False
|
||||
|
||||
|
||||
def test_format_duration_ru_pluralisation(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
assert tt.format_duration_ru(0) == "0 минут"
|
||||
assert tt.format_duration_ru(60) == "1 минута"
|
||||
assert tt.format_duration_ru(2 * 60) == "2 минуты"
|
||||
assert tt.format_duration_ru(5 * 60) == "5 минут"
|
||||
assert tt.format_duration_ru(11 * 60) == "11 минут"
|
||||
assert tt.format_duration_ru(3600) == "1 час"
|
||||
assert tt.format_duration_ru(2 * 3600) == "2 часа"
|
||||
assert tt.format_duration_ru(5 * 3600) == "5 часов"
|
||||
assert tt.format_duration_ru(3 * 3600 + 24 * 60) == "3 часа 24 минуты"
|
||||
|
||||
|
||||
def test_persists_atomic_write(isolated_state):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
tt.start_session(now=1_700_000_000)
|
||||
assert os.path.isfile(tt._PATH)
|
||||
with open(tt._PATH, encoding='utf-8') as f:
|
||||
on_disk = json.load(f)
|
||||
assert on_disk['current_session_start'] == 1_700_000_000
|
||||
|
||||
|
||||
def test_voice_entry_speaks_start_message(isolated_state, monkeypatch):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
spoken = []
|
||||
monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s))
|
||||
tt.do_tracker_start({}, '')
|
||||
assert spoken and 'Отсчёт' in spoken[0]
|
||||
# second call says already running
|
||||
tt.do_tracker_start({}, '')
|
||||
assert any('Уже считаю' in s for s in spoken)
|
||||
|
||||
|
||||
def test_voice_entry_today_reports_total(isolated_state, monkeypatch):
|
||||
tt = isolated_state('time_tracker_handlers', ['_PATH'])
|
||||
spoken = []
|
||||
monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s))
|
||||
# No data → "ничего не отработано"
|
||||
tt.do_tracker_today({}, '')
|
||||
assert spoken and 'ничего не отработано' in spoken[-1].lower()
|
||||
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]
|
||||
236
tests/test_wave68_handlers.py
Normal file
236
tests/test_wave68_handlers.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Tests for `wave68_handlers.py` — Python parity for Wave 6-8 Rust packs.
|
||||
|
||||
Each test captures speech via `set_speak(callback)` so we can assert on what
|
||||
J.A.R.V.I.S. would have said. Scheduler / memory state lives in tmp_path via
|
||||
the `isolated_state` fixture from conftest.py.
|
||||
|
||||
Implementation gotcha: `isolated_state` re-imports `memory_store` /
|
||||
`scheduler_store` etc with `_PATH` pointing at the per-test tmpdir, but the
|
||||
already-imported `wave68_handlers` keeps a reference to the OLD modules.
|
||||
`_rewire(...)` below re-binds those refs so handlers hit the tmp stores.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Make project root importable.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import wave68_handlers
|
||||
|
||||
|
||||
def _rewire(isolated_state, *modules):
|
||||
"""Re-import each named state module via `isolated_state`, then rebind
|
||||
the wave68_handlers reference so the handler picks up the tmp_path
|
||||
instance. Returns the list of (re-imported) modules.
|
||||
"""
|
||||
importlib.reload(wave68_handlers)
|
||||
out = []
|
||||
for name in modules:
|
||||
mod = isolated_state(name, ['_PATH', '_PROFILES_DIR'])
|
||||
setattr(wave68_handlers, name, mod)
|
||||
out.append(mod)
|
||||
return out
|
||||
|
||||
|
||||
def _capture_speak():
|
||||
captured = []
|
||||
wave68_handlers.set_speak(captured.append)
|
||||
return captured
|
||||
|
||||
|
||||
# ── memory admin ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_memory_count_uses_russian_plural(isolated_state):
|
||||
(mem,) = _rewire(isolated_state, 'memory_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_memory_count()
|
||||
assert "0 фактов" in spoken[0]
|
||||
|
||||
mem.remember('любимый чай', 'улун')
|
||||
spoken.clear()
|
||||
wave68_handlers.do_memory_count()
|
||||
assert "1 факт" in spoken[0]
|
||||
assert "факта" not in spoken[0] # singular, not "2 факта"
|
||||
|
||||
mem.remember('питомец', 'собака')
|
||||
mem.remember('город', 'москва')
|
||||
spoken.clear()
|
||||
wave68_handlers.do_memory_count()
|
||||
assert "3 факта" in spoken[0] # 2-4 → "факта"
|
||||
|
||||
for i in range(2):
|
||||
mem.remember(f'k{i}', f'v{i}')
|
||||
spoken.clear()
|
||||
wave68_handlers.do_memory_count()
|
||||
assert "5 фактов" in spoken[0] # 5+ → "фактов"
|
||||
|
||||
|
||||
def test_memory_list_speaks_empty_state(isolated_state):
|
||||
_rewire(isolated_state, 'memory_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_memory_list()
|
||||
assert "пуст" in spoken[0].lower()
|
||||
|
||||
|
||||
def test_memory_list_shows_recent_first(isolated_state):
|
||||
(mem,) = _rewire(isolated_state, 'memory_store')
|
||||
# Inject pre-aged record directly so timestamps are distinct without
|
||||
# waiting on the clock — `remember` uses wall-clock seconds and two
|
||||
# back-to-back calls in tests routinely tie.
|
||||
mem._load()
|
||||
mem._store['старый'] = {
|
||||
'key': 'старый', 'value': 'факт',
|
||||
'created_at': 100, 'last_used_at': 100, 'use_count': 0,
|
||||
}
|
||||
mem._store['новый'] = {
|
||||
'key': 'новый', 'value': 'факт',
|
||||
'created_at': 200, 'last_used_at': 200, 'use_count': 0,
|
||||
}
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_memory_list()
|
||||
text = spoken[0]
|
||||
# Both must appear, with новый (last_used_at=200) before старый (=100)
|
||||
assert 'новый' in text
|
||||
assert 'старый' in text
|
||||
assert text.index('новый') < text.index('старый')
|
||||
|
||||
|
||||
def test_memory_wipe_returns_count(isolated_state):
|
||||
(mem,) = _rewire(isolated_state, 'memory_store')
|
||||
mem.remember('a', '1')
|
||||
mem.remember('b', '2')
|
||||
mem.remember('c', '3')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_memory_wipe()
|
||||
assert "3" in spoken[0]
|
||||
assert len(mem.all_facts()) == 0
|
||||
|
||||
|
||||
def test_memory_wipe_on_empty_is_polite(isolated_state):
|
||||
_rewire(isolated_state, 'memory_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_memory_wipe()
|
||||
assert "пуста" in spoken[0]
|
||||
|
||||
|
||||
# ── cooking ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_match_recipe_finds_substrings():
|
||||
# Direct unit test on the matcher — no scheduler side effects.
|
||||
assert wave68_handlers.match_recipe("варю макароны") == (10, "макароны")
|
||||
assert wave68_handlers.match_recipe("ставлю чайник") == (4, "чайник")
|
||||
assert wave68_handlers.match_recipe("жарю омлет") == (5, "омлет")
|
||||
assert wave68_handlers.match_recipe("какая сегодня погода") is None
|
||||
assert wave68_handlers.match_recipe("") is None
|
||||
|
||||
|
||||
def test_cooking_schedules_a_task(isolated_state):
|
||||
(sched,) = _rewire(isolated_state, 'scheduler_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_cooking(voice="варю пасту 200 грамм")
|
||||
tasks = sched.list_all()
|
||||
assert len(tasks) == 1
|
||||
assert "паст" in tasks[0]['name'].lower()
|
||||
assert "10" in spoken[0]
|
||||
|
||||
|
||||
def test_cooking_unmatched_phrase_says_so(isolated_state):
|
||||
_rewire(isolated_state, 'scheduler_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_cooking(voice="какая погода в москве")
|
||||
assert "не понял" in spoken[0].lower()
|
||||
|
||||
|
||||
# ── leftoff ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_leftoff_pulls_memory_and_tasks(isolated_state):
|
||||
mem, sched = _rewire(isolated_state, 'memory_store', 'scheduler_store')
|
||||
mem.remember('текущий проект', 'jarvis рефакторинг')
|
||||
sched.add({
|
||||
"name": "Daily standup",
|
||||
"schedule": sched.parse_schedule("daily 09:00"),
|
||||
"action": {"type": "speak", "text": "standup"},
|
||||
})
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_leftoff(last_assistant_reply="Готовлю кофе, сэр.")
|
||||
text = spoken[0]
|
||||
assert "текущий проект" in text
|
||||
assert "Daily standup" in text
|
||||
assert "Готовлю кофе" in text
|
||||
|
||||
|
||||
def test_leftoff_empty_is_polite(isolated_state):
|
||||
_rewire(isolated_state, 'memory_store', 'scheduler_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_leftoff()
|
||||
assert "ничего" in spoken[0].lower()
|
||||
|
||||
|
||||
# ── trivia ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_trivia_returns_one_known_line():
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_trivia(lang="ru")
|
||||
assert spoken[0] in wave68_handlers._TRIVIA_RU
|
||||
|
||||
|
||||
def test_trivia_english_pool():
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_trivia(lang="en")
|
||||
assert spoken[0] in wave68_handlers._TRIVIA_EN
|
||||
|
||||
|
||||
# ── routines ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_good_night_cancels_one_shot_timers(isolated_state):
|
||||
sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store')
|
||||
# Pre-populate: one one-shot ("Таймер:") and one daily (should stay)
|
||||
sched.add({
|
||||
"name": "Таймер: яичница",
|
||||
"schedule": sched.parse_schedule("in 5 minutes"),
|
||||
"action": {"type": "speak", "text": "ready"},
|
||||
})
|
||||
sched.add({
|
||||
"name": "Daily briefing",
|
||||
"schedule": sched.parse_schedule("daily 09:00"),
|
||||
"action": {"type": "speak", "text": "morning"},
|
||||
})
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_good_night()
|
||||
remaining = [t['name'] for t in sched.list_all()]
|
||||
assert "Daily briefing" in remaining
|
||||
assert not any("Таймер:" in n for n in remaining)
|
||||
assert "1" in spoken[0] # cancelled-count appears in message
|
||||
|
||||
|
||||
def test_good_morning_reports_clear_or_busy_schedule(isolated_state):
|
||||
sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_good_morning()
|
||||
assert "чистое" in spoken[0].lower() or "clear" in spoken[0].lower()
|
||||
|
||||
sched.add({
|
||||
"name": "Task",
|
||||
"schedule": sched.parse_schedule("daily 10:00"),
|
||||
"action": {"type": "speak", "text": "x"},
|
||||
})
|
||||
spoken.clear()
|
||||
wave68_handlers.do_good_morning()
|
||||
assert "1" in spoken[0]
|
||||
|
||||
|
||||
def test_coffee_break_creates_5min_checkin(isolated_state):
|
||||
(sched,) = _rewire(isolated_state, 'scheduler_store')
|
||||
spoken = _capture_speak()
|
||||
wave68_handlers.do_coffee_break()
|
||||
tasks = sched.list_all()
|
||||
assert len(tasks) == 1
|
||||
assert "Чек-ин" in tasks[0]['name']
|
||||
assert spoken[0] # non-empty
|
||||
263
time_tracker_handlers.py
Normal file
263
time_tracker_handlers.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"""Time tracker — voice-driven daily work-time log.
|
||||
|
||||
Parity with the rust `time_tracker` command pack. Stores active session state
|
||||
and a list of completed sessions in `<script_dir>/time_tracker.json` with
|
||||
atomic write-through (write-then-rename).
|
||||
|
||||
Public API:
|
||||
do_tracker_start(action, voice) -> None
|
||||
do_tracker_stop(action, voice) -> None
|
||||
do_tracker_today(action, voice) -> None
|
||||
do_tracker_week(action, voice) -> None
|
||||
do_tracker_reset(action, voice) -> None
|
||||
|
||||
Lower-level helpers (also used by tests):
|
||||
load() -> dict
|
||||
save(data) -> None
|
||||
start_session(now=None) -> dict # returns updated state
|
||||
stop_session(now=None) -> tuple[dict, int|None] # (state, elapsed seconds)
|
||||
today_total(now=None) -> int # total seconds today (incl. open session)
|
||||
week_total(now=None) -> int # total seconds across last 7 days
|
||||
reset_today(now=None) -> tuple[int, bool] # (dropped_count, had_open)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time as _time
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_PATH = os.path.join(_HERE, 'time_tracker.json')
|
||||
|
||||
_speak_fn = print
|
||||
|
||||
|
||||
def set_speak(fn):
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
|
||||
|
||||
def _speak(text):
|
||||
try:
|
||||
_speak_fn(text)
|
||||
except Exception as exc:
|
||||
print(f"[tracker] speak: {exc}")
|
||||
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
def load() -> dict:
|
||||
if not os.path.isfile(_PATH):
|
||||
return {'current_session_start': None, 'sessions': []}
|
||||
try:
|
||||
with open(_PATH, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[tracker] corrupt store ({exc}) — starting empty")
|
||||
return {'current_session_start': None, 'sessions': []}
|
||||
if not isinstance(data, dict):
|
||||
return {'current_session_start': None, 'sessions': []}
|
||||
data.setdefault('current_session_start', None)
|
||||
data.setdefault('sessions', [])
|
||||
return data
|
||||
|
||||
|
||||
def save(data: dict) -> None:
|
||||
tmp = _PATH + '.tmp'
|
||||
try:
|
||||
with open(tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, _PATH)
|
||||
except OSError as exc:
|
||||
print(f"[tracker] save failed: {exc}")
|
||||
|
||||
|
||||
# ── local-day math ───────────────────────────────────────────────────────
|
||||
|
||||
def _local_today_start(now: int) -> int:
|
||||
"""Return the unix timestamp of local midnight on the day that contains
|
||||
`now`."""
|
||||
tm = _time.localtime(now)
|
||||
return now - (tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec)
|
||||
|
||||
|
||||
# ── operations ───────────────────────────────────────────────────────────
|
||||
|
||||
def start_session(now: int | None = None) -> dict:
|
||||
"""Open a new active session if none is running. Returns the new state."""
|
||||
if now is None:
|
||||
now = int(_time.time())
|
||||
data = load()
|
||||
if data.get('current_session_start') is None:
|
||||
data['current_session_start'] = now
|
||||
save(data)
|
||||
return data
|
||||
|
||||
|
||||
def stop_session(now: int | None = None) -> tuple[dict, int | None]:
|
||||
"""Close the open session (if any). Returns (state, elapsed_seconds)."""
|
||||
if now is None:
|
||||
now = int(_time.time())
|
||||
data = load()
|
||||
start_ts = data.get('current_session_start')
|
||||
if start_ts is None:
|
||||
return data, None
|
||||
elapsed = max(1, now - int(start_ts))
|
||||
sessions = data.get('sessions') or []
|
||||
sessions.append({'start': int(start_ts), 'end': now})
|
||||
data['sessions'] = sessions
|
||||
data['current_session_start'] = None
|
||||
save(data)
|
||||
return data, elapsed
|
||||
|
||||
|
||||
def _sum_overlap(sessions: list[dict], open_start: int | None,
|
||||
lo: int, hi: int) -> int:
|
||||
total = 0
|
||||
for sess in sessions:
|
||||
s = int(sess.get('start') or 0)
|
||||
e = int(sess.get('end') or 0)
|
||||
a = max(s, lo)
|
||||
b = min(e, hi)
|
||||
if b > a:
|
||||
total += b - a
|
||||
if open_start is not None:
|
||||
a = max(int(open_start), lo)
|
||||
if hi > a:
|
||||
total += hi - a
|
||||
return total
|
||||
|
||||
|
||||
def today_total(now: int | None = None) -> int:
|
||||
if now is None:
|
||||
now = int(_time.time())
|
||||
data = load()
|
||||
today_start = _local_today_start(now)
|
||||
return _sum_overlap(data.get('sessions') or [],
|
||||
data.get('current_session_start'),
|
||||
today_start, now)
|
||||
|
||||
|
||||
def week_total(now: int | None = None) -> int:
|
||||
if now is None:
|
||||
now = int(_time.time())
|
||||
data = load()
|
||||
today_start = _local_today_start(now)
|
||||
week_start = today_start - 6 * 86400
|
||||
return _sum_overlap(data.get('sessions') or [],
|
||||
data.get('current_session_start'),
|
||||
week_start, now)
|
||||
|
||||
|
||||
def reset_today(now: int | None = None) -> tuple[int, bool]:
|
||||
"""Drop today's completed sessions and stop any open one.
|
||||
Returns (dropped_count, had_open_session)."""
|
||||
if now is None:
|
||||
now = int(_time.time())
|
||||
data = load()
|
||||
today_start = _local_today_start(now)
|
||||
sessions = data.get('sessions') or []
|
||||
kept = [s for s in sessions if int(s.get('start') or 0) < today_start]
|
||||
dropped = len(sessions) - len(kept)
|
||||
had_open = data.get('current_session_start') is not None
|
||||
data['sessions'] = kept
|
||||
data['current_session_start'] = None
|
||||
save(data)
|
||||
return dropped, had_open
|
||||
|
||||
|
||||
# ── formatting ───────────────────────────────────────────────────────────
|
||||
|
||||
def _ru_hour_word(n: int) -> str:
|
||||
n100 = n % 100
|
||||
if 11 <= n100 <= 14:
|
||||
return 'часов'
|
||||
n10 = n % 10
|
||||
if n10 == 1:
|
||||
return 'час'
|
||||
if 2 <= n10 <= 4:
|
||||
return 'часа'
|
||||
return 'часов'
|
||||
|
||||
|
||||
def _ru_minute_word(n: int) -> str:
|
||||
n100 = n % 100
|
||||
if 11 <= n100 <= 14:
|
||||
return 'минут'
|
||||
n10 = n % 10
|
||||
if n10 == 1:
|
||||
return 'минута'
|
||||
if 2 <= n10 <= 4:
|
||||
return 'минуты'
|
||||
return 'минут'
|
||||
|
||||
|
||||
def _ru_session_word(n: int) -> str:
|
||||
n100 = n % 100
|
||||
if 11 <= n100 <= 14:
|
||||
return 'сессий'
|
||||
n10 = n % 10
|
||||
if n10 == 1:
|
||||
return 'сессия'
|
||||
if 2 <= n10 <= 4:
|
||||
return 'сессии'
|
||||
return 'сессий'
|
||||
|
||||
|
||||
def format_duration_ru(seconds: int) -> str:
|
||||
seconds = max(0, int(seconds))
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
if h > 0 and m > 0:
|
||||
return f"{h} {_ru_hour_word(h)} {m} {_ru_minute_word(m)}"
|
||||
if h > 0:
|
||||
return f"{h} {_ru_hour_word(h)}"
|
||||
return f"{m} {_ru_minute_word(m)}"
|
||||
|
||||
|
||||
# ── voice entry points ───────────────────────────────────────────────────
|
||||
|
||||
def do_tracker_start(action, voice):
|
||||
data = load()
|
||||
if data.get('current_session_start') is not None:
|
||||
_speak("Уже считаю, сэр.")
|
||||
return
|
||||
start_session()
|
||||
_speak("Отсчёт пошёл.")
|
||||
|
||||
|
||||
def do_tracker_stop(action, voice):
|
||||
_, elapsed = stop_session()
|
||||
if elapsed is None:
|
||||
_speak("Трекер не запущен, сэр.")
|
||||
return
|
||||
_speak(f"Записал. Сессия: {format_duration_ru(elapsed)}.")
|
||||
|
||||
|
||||
def do_tracker_today(action, voice):
|
||||
total = today_total()
|
||||
if total < 60:
|
||||
_speak("Сегодня ещё ничего не отработано, сэр.")
|
||||
return
|
||||
_speak(f"Сегодня отработано {format_duration_ru(total)}.")
|
||||
|
||||
|
||||
def do_tracker_week(action, voice):
|
||||
total = week_total()
|
||||
if total < 60:
|
||||
_speak("За эту неделю ничего не отработано, сэр.")
|
||||
return
|
||||
_speak(f"За неделю отработано {format_duration_ru(total)}.")
|
||||
|
||||
|
||||
def do_tracker_reset(action, voice):
|
||||
dropped, had_open = reset_today()
|
||||
if dropped == 0 and not had_open:
|
||||
_speak("Сегодня сбрасывать нечего, сэр.")
|
||||
return
|
||||
parts = ["Подтверждаю: трекер за сегодня очищен"]
|
||||
if dropped > 0:
|
||||
parts.append(f", удалено {dropped} {_ru_session_word(dropped)}")
|
||||
if had_open:
|
||||
parts.append(", активный отсчёт остановлен")
|
||||
parts.append(".")
|
||||
_speak("".join(parts))
|
||||
46
tts.py
46
tts.py
|
|
@ -1,3 +1,14 @@
|
|||
"""TTS — Silero ru_v3 voice synth with lazy initialisation.
|
||||
|
||||
Why lazy: torch.hub.load() blocks for 30-120 seconds on the first run
|
||||
(downloading the model) and produces no progress output beyond a single
|
||||
"Using cache found in..." line. If this runs at MODULE IMPORT time, the
|
||||
whole jarvis-python startup looks frozen — exactly the bug the user hit.
|
||||
|
||||
Fix: defer the load until first synth call. main.py prints its startup
|
||||
banner first, then this module loads on demand. Side effect: the first
|
||||
"speak" is slow (3-4s), but every print before it is visible.
|
||||
"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
|
@ -14,13 +25,31 @@ speaker = 'aidar' # aidar, baya, kseniya, xenia, random
|
|||
put_accent = True
|
||||
put_yo = True
|
||||
device = torch.device('cpu') # cpu или gpu
|
||||
text = "Хауди Хо, друзья!!!"
|
||||
|
||||
model, _ = torch.hub.load(repo_or_dir='snakers4/silero-models',
|
||||
# Initialised on first synthesize() call.
|
||||
_model = None
|
||||
|
||||
|
||||
def _ensure_model():
|
||||
"""Load Silero on first use. Subsequent calls are no-ops.
|
||||
|
||||
Prints a one-line progress hint so the user can tell the assistant is
|
||||
busy downloading rather than hung — critical for the first run when
|
||||
the model isn't cached yet.
|
||||
"""
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
print("[tts] Loading Silero ru_v3 (first use — ~30s if model isn't cached)...")
|
||||
started = time.time()
|
||||
m, _ = torch.hub.load(repo_or_dir='snakers4/silero-models',
|
||||
model='silero_tts',
|
||||
language=language,
|
||||
speaker=model_id)
|
||||
model.to(device)
|
||||
m.to(device)
|
||||
_model = m
|
||||
print(f"[tts] Silero ready ({time.time() - started:.1f}s).")
|
||||
return _model
|
||||
|
||||
|
||||
def _post_process(audio):
|
||||
|
|
@ -45,11 +74,12 @@ def _post_process(audio):
|
|||
|
||||
|
||||
def synthesize(what: str):
|
||||
audio = model.apply_tts(text=what + "..",
|
||||
speaker=speaker,
|
||||
sample_rate=sample_rate,
|
||||
put_accent=put_accent,
|
||||
put_yo=put_yo)
|
||||
m = _ensure_model()
|
||||
audio = m.apply_tts(text=what + "..",
|
||||
speaker=speaker,
|
||||
sample_rate=sample_rate,
|
||||
put_accent=put_accent,
|
||||
put_yo=put_yo)
|
||||
return _post_process(audio)
|
||||
|
||||
|
||||
|
|
|
|||
152
vision_handler.py
Normal file
152
vision_handler.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Vision handler — port of `crates/jarvis-core/src/lua/api/vision.rs`.
|
||||
|
||||
Captures the primary screen via PowerShell System.Drawing, base64-encodes
|
||||
the PNG, sends to Groq's vision-capable model. Requires GROQ_TOKEN.
|
||||
|
||||
Action types in commands.yaml:
|
||||
vision_describe "что на экране" / "опиши экран"
|
||||
vision_read_error "прочитай ошибку"
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
_speak_fn = print
|
||||
|
||||
|
||||
def set_speak(fn):
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
|
||||
|
||||
def _speak(text):
|
||||
try:
|
||||
_speak_fn(text)
|
||||
except Exception as exc:
|
||||
print(f"[vision] speak: {exc}")
|
||||
|
||||
|
||||
_SCREENSHOT_PS_TEMPLATE = """
|
||||
Add-Type -AssemblyName System.Windows.Forms;
|
||||
Add-Type -AssemblyName System.Drawing;
|
||||
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;
|
||||
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height;
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp);
|
||||
$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size);
|
||||
$bmp.Save('{path}', [System.Drawing.Imaging.ImageFormat]::Png);
|
||||
$g.Dispose(); $bmp.Dispose();
|
||||
"""
|
||||
|
||||
|
||||
def _take_screenshot() -> str | None:
|
||||
"""Save full screen to a temp PNG, return path or None on failure."""
|
||||
fd, path = tempfile.mkstemp(prefix='jarvis-screen-', suffix='.png')
|
||||
os.close(fd)
|
||||
ps = _SCREENSHOT_PS_TEMPLATE.format(path=path.replace("'", "''"))
|
||||
try:
|
||||
subprocess.run(
|
||||
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],
|
||||
capture_output=True, timeout=10, check=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[vision] screenshot: {exc}")
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
if not os.path.isfile(path) or os.path.getsize(path) < 1000:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _vision_call(prompt: str, image_b64: str) -> str | None:
|
||||
# Vision is Groq-specific (Ollama doesn't expose vision via OpenAI-compat
|
||||
# endpoint in our stack). Use config.GROQ_TOKEN directly regardless of the
|
||||
# active text-LLM backend.
|
||||
try:
|
||||
import config as cfg
|
||||
except ImportError:
|
||||
return None
|
||||
if not getattr(cfg, 'GROQ_TOKEN', None):
|
||||
return None
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
client = OpenAI(
|
||||
api_key=cfg.GROQ_TOKEN,
|
||||
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
|
||||
)
|
||||
|
||||
model = os.environ.get('GROQ_VISION_MODEL', 'llama-3.2-11b-vision-preview')
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{'type': 'text', 'text': prompt},
|
||||
{'type': 'image_url', 'image_url': {
|
||||
'url': f'data:image/png;base64,{image_b64}',
|
||||
}},
|
||||
],
|
||||
}],
|
||||
max_tokens=400, temperature=0.2, timeout=30,
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
except Exception as exc:
|
||||
print(f"[vision] LLM call: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _describe(prompt: str) -> str | None:
|
||||
"""Take a screenshot and ask vision-LLM. Returns description or None."""
|
||||
path = _take_screenshot()
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
b64 = base64.b64encode(f.read()).decode('ascii')
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
return _vision_call(prompt, b64)
|
||||
|
||||
|
||||
def do_vision_describe(action, voice):
|
||||
_speak("Сейчас посмотрю.")
|
||||
prompt = (
|
||||
"Опиши коротко (1-3 предложения) что сейчас на экране. По-русски."
|
||||
)
|
||||
result = _describe(prompt)
|
||||
if not result:
|
||||
_speak("Не удалось разобрать экран. Проверь GROQ_TOKEN.")
|
||||
return
|
||||
_speak(result)
|
||||
|
||||
|
||||
def do_vision_read_error(action, voice):
|
||||
_speak("Смотрю на ошибку.")
|
||||
prompt = (
|
||||
"Найди на экране текст ошибки (сообщение об ошибке, stack trace, диалог "
|
||||
"об исключении). Прочитай ключевое сообщение и одним коротким "
|
||||
"предложением подскажи возможную причину. Если ошибки нет — скажи "
|
||||
"'ошибок не вижу'. Отвечай по-русски. 1-3 предложения максимум."
|
||||
)
|
||||
result = _describe(prompt)
|
||||
if not result:
|
||||
_speak("Не получилось прочитать.")
|
||||
return
|
||||
_speak(result)
|
||||
613
wave1_handlers.py
Normal file
613
wave1_handlers.py
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
"""Wave 1 handler module — port of 10 rust packs from c4b2261.
|
||||
|
||||
magic_8ball, github_issues, weather_extended, rss_reader, ics_event,
|
||||
backup, clip_history, notif_queue, password_vault, habit_streaks.
|
||||
|
||||
Imported by extensions.py + dispatched from main.py.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import json as _json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
import memory_store
|
||||
|
||||
_speak_fn = print
|
||||
|
||||
|
||||
def set_speak(fn):
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
|
||||
|
||||
def _speak(text):
|
||||
try:
|
||||
_speak_fn(text)
|
||||
except Exception as exc:
|
||||
print(f"[wave1] speak: {exc}")
|
||||
|
||||
|
||||
def _ps(cmd, timeout=15):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd],
|
||||
capture_output=True, text=True, encoding='utf-8', timeout=timeout,
|
||||
)
|
||||
return res.returncode == 0, (res.stdout or '').strip(), (res.stderr or '')
|
||||
except Exception as exc:
|
||||
return False, '', str(exc)
|
||||
|
||||
|
||||
def _gh(*args, timeout=15):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
['gh', *args],
|
||||
capture_output=True, text=True, encoding='utf-8', timeout=timeout,
|
||||
)
|
||||
return res.returncode == 0, res.stdout, res.stderr
|
||||
except Exception as exc:
|
||||
return False, '', str(exc)
|
||||
|
||||
|
||||
def _http_json(url, timeout=10):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return _json.loads(r.read().decode())
|
||||
except Exception as exc:
|
||||
print(f"[wave1] http: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
# ── magic_8ball ────────────────────────────────────────────────────────────
|
||||
|
||||
_8BALL = [
|
||||
"Без сомнения, сэр.", "Однозначно да.", "Можете на это рассчитывать.",
|
||||
"Скорее всего да.", "Перспективы хорошие.", "Знаки указывают на да.",
|
||||
"Пока неясно, попробуйте позже.", "Сосредоточьтесь и спросите снова.",
|
||||
"Не могу предсказать сейчас.", "Лучше не говорить.",
|
||||
"Не рассчитывайте на это.", "Мой ответ — нет.", "Источники говорят нет.",
|
||||
"Перспективы не очень.", "Очень сомнительно.",
|
||||
]
|
||||
|
||||
|
||||
def do_magic_8ball(action, voice):
|
||||
_speak(random.choice(_8BALL))
|
||||
|
||||
|
||||
# ── github_issues ─────────────────────────────────────────────────────────
|
||||
|
||||
def do_github_list_issues(action, voice):
|
||||
repo = memory_store.recall("github.repo")
|
||||
if not repo:
|
||||
_speak("Сначала укажите репозиторий: текущий репо owner/repo.")
|
||||
return
|
||||
ok, out, _ = _gh('issue', 'list', '--repo', repo, '--state', 'open',
|
||||
'--json', 'number,title,labels', '--limit', '10')
|
||||
if not ok:
|
||||
_speak("gh CLI не отвечает.")
|
||||
return
|
||||
try:
|
||||
items = _json.loads(out or '[]')
|
||||
except _json.JSONDecodeError:
|
||||
items = []
|
||||
if not items:
|
||||
_speak("Открытых issues нет.")
|
||||
return
|
||||
titles = [i.get('title', '') for i in items[:3] if i.get('title')]
|
||||
line = f"{len(items)} открытых issues. "
|
||||
if titles:
|
||||
line += "Первые: " + ". ".join(titles) + "."
|
||||
_speak(line)
|
||||
|
||||
|
||||
def do_github_my_issues(action, voice):
|
||||
ok, out, _ = _gh('issue', 'list', '--assignee', '@me', '--state', 'open',
|
||||
'--json', 'number,title,repository', '--limit', '10')
|
||||
if not ok:
|
||||
_speak("gh CLI не отвечает.")
|
||||
return
|
||||
try:
|
||||
items = _json.loads(out or '[]')
|
||||
except _json.JSONDecodeError:
|
||||
items = []
|
||||
if not items:
|
||||
_speak("Тебе ничего не назначено.")
|
||||
return
|
||||
titles = [i.get('title', '') for i in items[:3] if i.get('title')]
|
||||
line = f"На вас {len(items)} issues. "
|
||||
if titles:
|
||||
line += "Первые: " + ". ".join(titles) + "."
|
||||
_speak(line)
|
||||
|
||||
|
||||
# ── weather_extended ──────────────────────────────────────────────────────
|
||||
|
||||
def _weather_location():
|
||||
lat = memory_store.recall("weather.lat")
|
||||
lon = memory_store.recall("weather.lon")
|
||||
city = memory_store.recall("weather.city")
|
||||
if lat and lon:
|
||||
return lat, lon, city or "вашем городе"
|
||||
ip = _http_json("https://ipinfo.io/json", timeout=5)
|
||||
if not ip or not ip.get('loc'):
|
||||
return None, None, None
|
||||
parts = ip['loc'].split(',')
|
||||
if len(parts) != 2:
|
||||
return None, None, None
|
||||
lat, lon = parts[0], parts[1]
|
||||
city = ip.get('city', 'ваш город')
|
||||
memory_store.remember("weather.lat", lat)
|
||||
memory_store.remember("weather.lon", lon)
|
||||
memory_store.remember("weather.city", city)
|
||||
return lat, lon, city
|
||||
|
||||
|
||||
def _weather_code_ru(code):
|
||||
if code == 0: return "ясно"
|
||||
if code <= 3: return "переменная облачность"
|
||||
if 51 <= code <= 67: return "дождь"
|
||||
if 71 <= code <= 77: return "снег"
|
||||
if code >= 95: return "гроза"
|
||||
return "переменно"
|
||||
|
||||
|
||||
def do_weather_tomorrow(action, voice):
|
||||
lat, lon, city = _weather_location()
|
||||
if not lat:
|
||||
_speak("Не получилось узнать местоположение.")
|
||||
return
|
||||
url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
|
||||
f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code"
|
||||
f"&timezone=auto&forecast_days=2")
|
||||
data = _http_json(url, timeout=10)
|
||||
if not data or 'daily' not in data:
|
||||
_speak("Прогноз недоступен.")
|
||||
return
|
||||
d = data['daily']
|
||||
if len(d.get('temperature_2m_max', [])) < 2:
|
||||
_speak("Прогноз неполный.")
|
||||
return
|
||||
tmax = d['temperature_2m_max'][1]
|
||||
tmin = d['temperature_2m_min'][1]
|
||||
rain = d.get('precipitation_sum', [0, 0])[1] or 0
|
||||
code = d.get('weather_code', [0, 0])[1] or 0
|
||||
line = f"В {city} завтра {_weather_code_ru(code)}. От {round(tmin)} до {round(tmax)} градусов."
|
||||
if rain > 5:
|
||||
line += " Возможны осадки."
|
||||
_speak(line)
|
||||
|
||||
|
||||
def do_weather_week(action, voice):
|
||||
lat, lon, city = _weather_location()
|
||||
if not lat:
|
||||
_speak("Не получилось узнать местоположение.")
|
||||
return
|
||||
url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
|
||||
f"&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7")
|
||||
data = _http_json(url, timeout=10)
|
||||
if not data or 'daily' not in data:
|
||||
_speak("Прогноз недоступен.")
|
||||
return
|
||||
maxes = data['daily'].get('temperature_2m_max', [])
|
||||
mins = data['daily'].get('temperature_2m_min', [])
|
||||
if not maxes:
|
||||
_speak("Прогноз неполный.")
|
||||
return
|
||||
wk_max = max(maxes)
|
||||
wk_min = min(mins)
|
||||
_speak(f"Неделя в {city}: от {round(wk_min)} до {round(wk_max)} градусов.")
|
||||
|
||||
|
||||
# ── rss_reader ────────────────────────────────────────────────────────────
|
||||
|
||||
def do_rss_add(action, voice):
|
||||
body = (voice or '').strip()
|
||||
low = body.lower()
|
||||
for trig in ('добавь rss', 'подпишись на', 'запомни ленту', 'добавь ленту'):
|
||||
if low.startswith(trig):
|
||||
body = body[len(trig):].strip(' ,.:')
|
||||
break
|
||||
m = re.search(r'(https?://\S+)', body)
|
||||
if not m:
|
||||
_speak("Не нашёл URL.")
|
||||
return
|
||||
url = m.group(1)
|
||||
domain = url.split('://', 1)[1].split('/')[0]
|
||||
memory_store.remember(f"rss.{domain}", url)
|
||||
_speak(f"Лента {domain} добавлена.")
|
||||
|
||||
|
||||
def do_rss_read(action, voice):
|
||||
feed_url = None
|
||||
feed_name = None
|
||||
for rec in memory_store.all_facts():
|
||||
if rec['key'].startswith('rss.'):
|
||||
feed_url = rec['value']
|
||||
feed_name = rec['key'][4:]
|
||||
break
|
||||
if not feed_url:
|
||||
_speak("Лент не добавлено. Скажите 'добавь rss и URL'.")
|
||||
return
|
||||
try:
|
||||
with urllib.request.urlopen(feed_url, timeout=10) as r:
|
||||
body = r.read().decode('utf-8', errors='replace')
|
||||
except Exception as exc:
|
||||
print(f"[wave1] rss fetch: {exc}")
|
||||
_speak("Не получилось загрузить ленту.")
|
||||
return
|
||||
titles = re.findall(r'<title[^>]*>([^<]+)</title>', body)
|
||||
if len(titles) < 2:
|
||||
_speak("В ленте нет заголовков.")
|
||||
return
|
||||
sample = titles[1:4] # skip channel title
|
||||
_speak(f"Из {feed_name}. " + ". ".join(sample) + ".")
|
||||
|
||||
|
||||
def do_rss_list(action, voice):
|
||||
feeds = [r['key'][4:] for r in memory_store.all_facts() if r['key'].startswith('rss.')]
|
||||
if not feeds:
|
||||
_speak("Лент пока нет.")
|
||||
return
|
||||
_speak("Подписан на: " + ", ".join(feeds) + ".")
|
||||
|
||||
|
||||
# ── ics_event ─────────────────────────────────────────────────────────────
|
||||
|
||||
def do_ics_create(action, voice):
|
||||
body = (voice or '').strip()
|
||||
low = body.lower()
|
||||
for trig in ('добавь встречу', 'создай событие', 'создай встречу',
|
||||
'запиши встречу', 'новая встреча'):
|
||||
if low.startswith(trig):
|
||||
body = body[len(trig):].strip(' ,.:')
|
||||
break
|
||||
if not body:
|
||||
_speak("Опишите встречу.")
|
||||
return
|
||||
|
||||
# Find time HH:MM or "в HH"
|
||||
m = re.search(r'(\d{1,2})[:\-\s](\d{2})', body)
|
||||
if m:
|
||||
hh, mm = int(m.group(1)), int(m.group(2))
|
||||
else:
|
||||
m = re.search(r'в\s+(\d{1,2})', body)
|
||||
hh = int(m.group(1)) if m else 9
|
||||
mm = 0
|
||||
hh = max(0, min(23, hh))
|
||||
mm = max(0, min(59, mm))
|
||||
|
||||
today = dt.date.today()
|
||||
low2 = body.lower()
|
||||
if 'сегодня' in low2:
|
||||
target = today
|
||||
elif 'послезавтра' in low2:
|
||||
target = today + dt.timedelta(days=2)
|
||||
else:
|
||||
target = today + dt.timedelta(days=1)
|
||||
|
||||
summary = re.sub(r'\d{1,2}[:\-\s]\d{2}', '', body)
|
||||
summary = re.sub(r'в\s+\d{1,2}', '', summary)
|
||||
summary = re.sub(r'(сегодня|послезавтра|завтра)', '', summary, flags=re.IGNORECASE)
|
||||
summary = summary.strip(' ,.:') or "Встреча"
|
||||
|
||||
now = dt.datetime.now()
|
||||
now_stamp = now.strftime('%Y%m%dT%H%M%S')
|
||||
dt_start = f"{target.strftime('%Y%m%d')}T{hh:02d}{mm:02d}00"
|
||||
end_hh = (hh + 1) % 24
|
||||
dt_end = f"{target.strftime('%Y%m%d')}T{end_hh:02d}{mm:02d}00"
|
||||
uid = f"jarvis-{now_stamp}-{random.randint(1000, 9999)}"
|
||||
|
||||
ics = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//J.A.R.V.I.S.//EN\r\n"
|
||||
"BEGIN:VEVENT\r\n"
|
||||
f"UID:{uid}\r\nDTSTAMP:{now_stamp}\r\n"
|
||||
f"DTSTART:{dt_start}\r\nDTEND:{dt_end}\r\n"
|
||||
f"SUMMARY:{summary}\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
)
|
||||
|
||||
userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public')
|
||||
out_dir = os.path.join(userprofile, 'Documents', 'jarvis-events')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, f"event-{now_stamp}.ics")
|
||||
try:
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
f.write(ics)
|
||||
except OSError as exc:
|
||||
print(f"[wave1] ics write: {exc}")
|
||||
_speak("Не получилось создать файл.")
|
||||
return
|
||||
|
||||
# Open via default handler.
|
||||
os.startfile(out_path)
|
||||
_speak(f"Встреча создана: {summary} в {hh:02d}:{mm:02d}.")
|
||||
|
||||
|
||||
# ── backup ────────────────────────────────────────────────────────────────
|
||||
|
||||
def do_backup_export(action, voice):
|
||||
cfg_dir = memory_store._PATH and os.path.dirname(memory_store._PATH) or \
|
||||
os.path.dirname(os.path.abspath(__file__))
|
||||
appdata = os.environ.get('APPDATA', '')
|
||||
candidates = []
|
||||
# Search in script-relative + APPDATA/Jarvis
|
||||
for base in (cfg_dir, os.path.join(appdata, 'Jarvis') if appdata else None):
|
||||
if not base or not os.path.isdir(base):
|
||||
continue
|
||||
for fname in ('long_term_memory.json', 'schedule.json', 'macros.json',
|
||||
'active_profile.txt', 'llm_backend.txt', 'settings.json'):
|
||||
p = os.path.join(base, fname)
|
||||
if os.path.isfile(p):
|
||||
candidates.append(p)
|
||||
prof_dir = os.path.join(base, 'profiles')
|
||||
if os.path.isdir(prof_dir):
|
||||
for f in os.listdir(prof_dir):
|
||||
p = os.path.join(prof_dir, f)
|
||||
if os.path.isfile(p):
|
||||
candidates.append(p)
|
||||
if not candidates:
|
||||
_speak("Нечего бекапить.")
|
||||
return
|
||||
|
||||
userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public')
|
||||
stamp = dt.datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||
out_zip = os.path.join(userprofile, 'Documents', f'jarvis-backup-{stamp}.zip')
|
||||
try:
|
||||
with zipfile.ZipFile(out_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for src in candidates:
|
||||
zf.write(src, os.path.basename(src))
|
||||
except OSError as exc:
|
||||
print(f"[wave1] backup: {exc}")
|
||||
_speak("Не получилось создать бекап.")
|
||||
return
|
||||
_speak(f"Бекап сохранён, {len(candidates)} файлов.")
|
||||
|
||||
|
||||
# ── clip_history ──────────────────────────────────────────────────────────
|
||||
|
||||
def _get_clipboard():
|
||||
"""Read current clipboard via PowerShell."""
|
||||
ok, out, _ = _ps("Get-Clipboard -Raw", timeout=5)
|
||||
return out if ok else ''
|
||||
|
||||
|
||||
def _set_clipboard(text):
|
||||
"""Write to clipboard via PowerShell. None/empty → clears."""
|
||||
try:
|
||||
subprocess.run(
|
||||
['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input'],
|
||||
input=(text or ''), text=True, encoding='utf-8', timeout=10, check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[wave1] clipboard set: {exc}")
|
||||
|
||||
|
||||
def do_clip_save(action, voice):
|
||||
content = _get_clipboard()
|
||||
if not content:
|
||||
_speak("Буфер пуст.")
|
||||
return
|
||||
# Shift
|
||||
for i in range(19, 0, -1):
|
||||
prev = memory_store.recall(f"clip.{i - 1}")
|
||||
if prev:
|
||||
memory_store.remember(f"clip.{i}", prev)
|
||||
trimmed = content if len(content) <= 2000 else content[:2000] + "..."
|
||||
memory_store.remember("clip.0", trimmed)
|
||||
preview = trimmed[:40].replace('\n', ' ').replace('\r', ' ')
|
||||
_speak(f"Запомнил: {preview}.")
|
||||
|
||||
|
||||
def do_clip_list(action, voice):
|
||||
count = 0
|
||||
previews = []
|
||||
for i in range(20):
|
||||
val = memory_store.recall(f"clip.{i}")
|
||||
if val:
|
||||
count += 1
|
||||
if len(previews) < 3:
|
||||
p = val[:30].replace('\n', ' ').replace('\r', ' ')
|
||||
previews.append(p)
|
||||
if count == 0:
|
||||
_speak("История буфера пуста.")
|
||||
return
|
||||
_speak("В истории буфера: " + " | ".join(previews) + f". Всего {count} записей.")
|
||||
|
||||
|
||||
def do_clip_restore(action, voice):
|
||||
low = (voice or '').lower()
|
||||
idx = 0
|
||||
if 'втор' in low: idx = 1
|
||||
elif 'трет' in low: idx = 2
|
||||
elif 'четверт' in low: idx = 3
|
||||
elif 'пят' in low: idx = 4
|
||||
m = re.search(r'(\d+)', low)
|
||||
if m:
|
||||
idx = int(m.group(1)) - 1
|
||||
content = memory_store.recall(f"clip.{idx}")
|
||||
if not content:
|
||||
_speak("В истории нет такой записи.")
|
||||
return
|
||||
_set_clipboard(content)
|
||||
preview = content[:30].replace('\n', ' ')
|
||||
_speak(f"Восстановил: {preview}.")
|
||||
|
||||
|
||||
# ── notif_queue ───────────────────────────────────────────────────────────
|
||||
|
||||
def do_notif_missed(action, voice):
|
||||
notifs = []
|
||||
for rec in memory_store.all_facts():
|
||||
if rec['key'].startswith('notif.'):
|
||||
notifs.append((rec.get('last_used_at', 0), rec['value']))
|
||||
if not notifs:
|
||||
_speak("Ничего не пропустил.")
|
||||
return
|
||||
notifs.sort(reverse=True)
|
||||
sample = notifs[:5]
|
||||
_speak(f"Пропущено {len(notifs)} уведомлений. " + ". ".join(n[1] for n in sample) + ".")
|
||||
|
||||
|
||||
def do_notif_clear(action, voice):
|
||||
count = 0
|
||||
for rec in memory_store.all_facts():
|
||||
if rec['key'].startswith('notif.'):
|
||||
memory_store.forget(rec['key'])
|
||||
count += 1
|
||||
_speak("Уведомлений и так нет." if count == 0 else f"Очистил {count} уведомлений.")
|
||||
|
||||
|
||||
# ── password_vault ────────────────────────────────────────────────────────
|
||||
|
||||
def _dpapi_encrypt(plaintext):
|
||||
escaped = plaintext.replace("'", "''")
|
||||
ps = (
|
||||
"Add-Type -AssemblyName System.Security;"
|
||||
f"$bytes = [System.Text.Encoding]::UTF8.GetBytes('{escaped}');"
|
||||
"$enc = [System.Security.Cryptography.ProtectedData]::Protect("
|
||||
"$bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
|
||||
"Write-Output ([Convert]::ToBase64String($enc))"
|
||||
)
|
||||
ok, out, _ = _ps(ps, timeout=10)
|
||||
return out if ok else None
|
||||
|
||||
|
||||
def _dpapi_decrypt(b64):
|
||||
ps = (
|
||||
"Add-Type -AssemblyName System.Security;"
|
||||
f"$bytes = [Convert]::FromBase64String('{b64}');"
|
||||
"try {"
|
||||
" $dec = [System.Security.Cryptography.ProtectedData]::Unprotect("
|
||||
" $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
|
||||
" [System.Text.Encoding]::UTF8.GetString($dec) | Set-Clipboard;"
|
||||
" Write-Output 'OK' } catch { Write-Output 'ERR' }"
|
||||
)
|
||||
ok, out, _ = _ps(ps, timeout=10)
|
||||
return ok and out == 'OK'
|
||||
|
||||
|
||||
def _vault_strip_trigger(voice):
|
||||
low = (voice or '').lower()
|
||||
name = voice or ''
|
||||
for trig in ('покажи пароль от', 'достань пароль от', 'пароль от',
|
||||
'сохрани пароль от', 'запомни пароль от', 'дай пароль',
|
||||
'пароль для', 'password for', 'get password',
|
||||
'save password for', 'remember password for'):
|
||||
idx = low.find(trig)
|
||||
if idx >= 0:
|
||||
# find same trigger in original-case voice
|
||||
name = name[idx + len(trig):].strip(' ,.:')
|
||||
break
|
||||
return name.strip()
|
||||
|
||||
|
||||
def do_vault_save(action, voice):
|
||||
name = _vault_strip_trigger(voice)
|
||||
if not name:
|
||||
_speak("Имя сервиса не указано.")
|
||||
return
|
||||
plaintext = _get_clipboard()
|
||||
if not plaintext:
|
||||
_speak("Скопируйте пароль в буфер сначала.")
|
||||
return
|
||||
b64 = _dpapi_encrypt(plaintext)
|
||||
if not b64:
|
||||
_speak("Не получилось зашифровать.")
|
||||
return
|
||||
memory_store.remember(f"vault.{name}", b64)
|
||||
_set_clipboard("")
|
||||
_speak(f"Сохранил пароль от {name}.")
|
||||
|
||||
|
||||
def do_vault_get(action, voice):
|
||||
name = _vault_strip_trigger(voice)
|
||||
if not name:
|
||||
_speak("От чего пароль?")
|
||||
return
|
||||
b64 = memory_store.recall(f"vault.{name}")
|
||||
if not b64:
|
||||
_speak(f"Пароля для {name} нет.")
|
||||
return
|
||||
if not _dpapi_decrypt(b64):
|
||||
_speak("Не получилось расшифровать.")
|
||||
return
|
||||
# Clear clipboard 30 seconds later via background thread.
|
||||
import threading
|
||||
def _clear():
|
||||
import time
|
||||
time.sleep(30)
|
||||
_set_clipboard("")
|
||||
threading.Thread(target=_clear, daemon=True).start()
|
||||
_speak(f"Пароль от {name} в буфере на 30 секунд.")
|
||||
|
||||
|
||||
def do_vault_list(action, voice):
|
||||
names = [r['key'][6:] for r in memory_store.all_facts() if r['key'].startswith('vault.')]
|
||||
if not names:
|
||||
_speak("В хранилище паролей пусто.")
|
||||
return
|
||||
_speak(f"Паролей сохранено {len(names)}: " + ", ".join(names) + ".")
|
||||
|
||||
|
||||
# ── habit_streaks ─────────────────────────────────────────────────────────
|
||||
|
||||
def _habit_from_voice(voice):
|
||||
low = (voice or '').lower()
|
||||
if 'вод' in low: return 'water'
|
||||
if 'размял' in low or 'зарядк' in low: return 'stretch'
|
||||
if 'глаз' in low: return 'eyes'
|
||||
# Strip leading trigger and use first word
|
||||
for trig in ('отметь привычку', 'выполнил привычку', 'выполнил', 'отметь',
|
||||
'check in habit'):
|
||||
idx = low.find(trig)
|
||||
if idx >= 0:
|
||||
body = (voice or '')[idx + len(trig):].strip(' ,.:').strip()
|
||||
return body or 'general'
|
||||
return 'general'
|
||||
|
||||
|
||||
def do_habit_checkin(action, voice):
|
||||
habit = _habit_from_voice(voice)
|
||||
today = dt.date.today().strftime('%Y-%m-%d')
|
||||
memory_store.remember(f"habit_streak.{habit}.{today}", "1")
|
||||
_speak(f"Отметил {habit} за сегодня.")
|
||||
|
||||
|
||||
def do_habit_streak(action, voice):
|
||||
by_habit = {}
|
||||
for rec in memory_store.all_facts():
|
||||
m = re.match(r'^habit_streak\.([^.]+)\.(.+)$', rec['key'])
|
||||
if m:
|
||||
habit, date = m.group(1), m.group(2)
|
||||
by_habit.setdefault(habit, set()).add(date)
|
||||
if not by_habit:
|
||||
_speak("Привычек ещё не отмечено. Скажи 'выполнил привычку' после события.")
|
||||
return
|
||||
today = dt.date.today()
|
||||
results = []
|
||||
for habit, dates in by_habit.items():
|
||||
streak = 0
|
||||
for d in range(31):
|
||||
day = (today - dt.timedelta(days=d)).strftime('%Y-%m-%d')
|
||||
if day in dates:
|
||||
streak += 1
|
||||
else:
|
||||
break
|
||||
results.append((habit, streak))
|
||||
results.sort(key=lambda x: -x[1])
|
||||
sample = results[:3]
|
||||
lines = []
|
||||
for habit, streak in sample:
|
||||
if streak == 0:
|
||||
lines.append(f"{habit} прервана")
|
||||
else:
|
||||
unit = 'день' if streak == 1 else ('дня' if streak < 5 else 'дней')
|
||||
lines.append(f"{habit} {streak} {unit}")
|
||||
_speak("Стрики: " + ", ".join(lines) + ".")
|
||||
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)
|
||||
278
wave68_handlers.py
Normal file
278
wave68_handlers.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""Python parity for Wave 6-8 voice packs that were Rust-only at first.
|
||||
|
||||
Covers:
|
||||
- memory_admin: count / list / wipe
|
||||
- cooking: dish-name → preset-duration timer via scheduler
|
||||
- leftoff: cross-state recap (no LLM)
|
||||
- trivia: random MCU one-liner
|
||||
- routines: good_night / good_morning / coffee_break
|
||||
|
||||
Style: each `do_*` returns nothing; speech goes through the `set_speak`-
|
||||
injected callback, mirroring how the other handlers (wave1_handlers,
|
||||
personality_handlers, ...) work. Keeps the handler module testable in
|
||||
isolation since the speak fn is a thin dependency.
|
||||
"""
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
import memory_store
|
||||
import scheduler_store
|
||||
import profiles_store
|
||||
|
||||
|
||||
_speak: Optional[Callable[[str], None]] = None
|
||||
|
||||
|
||||
def set_speak(fn: Callable[[str], None]) -> None:
|
||||
"""Wired by extensions.py at startup; lets main.py route TTS through whichever
|
||||
backend the user has active (Silero / Piper / SAPI)."""
|
||||
global _speak
|
||||
_speak = fn
|
||||
|
||||
|
||||
def _say(text: str) -> None:
|
||||
if _speak is not None:
|
||||
_speak(text)
|
||||
else:
|
||||
# Tests don't always wire a speak fn — fall back to print so the test
|
||||
# can still assert on the captured content via capsys/caplog.
|
||||
print(f"[wave68] {text}")
|
||||
|
||||
|
||||
# ─── Memory admin ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ru_plural_facts(n: int) -> str:
|
||||
"""Russian declension: 1 факт, 2-4 факта, 5+ фактов, 11-14 exception."""
|
||||
m100 = n % 100
|
||||
if 11 <= m100 <= 14:
|
||||
return "фактов"
|
||||
m10 = n % 10
|
||||
if m10 == 1:
|
||||
return "факт"
|
||||
if 2 <= m10 <= 4:
|
||||
return "факта"
|
||||
return "фактов"
|
||||
|
||||
|
||||
def do_memory_count(action=None, voice=None) -> None:
|
||||
n = len(memory_store.all_facts())
|
||||
_say(f"В памяти {n} {_ru_plural_facts(n)}, сэр.")
|
||||
|
||||
|
||||
def do_memory_list(action=None, voice=None) -> None:
|
||||
facts = memory_store.all_facts()
|
||||
if not facts:
|
||||
_say("Память пуста, сэр.")
|
||||
return
|
||||
# Sort by recency
|
||||
facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True)
|
||||
preview = facts[:5]
|
||||
rest = max(0, len(facts) - len(preview))
|
||||
parts = [f"{f['key']} — {(f.get('value') or '')[:60]}" for f in preview]
|
||||
msg = ", ".join(parts)
|
||||
if rest > 0:
|
||||
msg += f". И ещё {rest}."
|
||||
_say(msg)
|
||||
|
||||
|
||||
def do_memory_wipe(action=None, voice=None) -> None:
|
||||
n = memory_store.clear_all()
|
||||
if n == 0:
|
||||
_say("Память уже пуста, сэр.")
|
||||
else:
|
||||
_say(f"Стёр {n} записей. Память чиста.")
|
||||
|
||||
|
||||
# ─── Cooking timer ─────────────────────────────────────────────────────────
|
||||
|
||||
# (lowercase substring, minutes, ru-label, en-label)
|
||||
_RECIPES = [
|
||||
("чайник", 4, "чайник", "kettle"),
|
||||
("французск", 4, "френч-пресс", "french press"),
|
||||
("чай", 5, "чай", "tea"),
|
||||
("омлет", 5, "омлет", "omelette"),
|
||||
("яичниц", 5, "яичница", "fried eggs"),
|
||||
("кофе", 5, "кофе", "coffee"),
|
||||
("яйц", 8, "яйца", "eggs"),
|
||||
("макарон", 10, "макароны", "pasta"),
|
||||
("паст", 10, "паста", "pasta"),
|
||||
("пицц", 15, "пицца", "pizza"),
|
||||
("рис", 18, "рис", "rice"),
|
||||
("греч", 20, "гречка", "buckwheat"),
|
||||
("каш", 20, "каша", "porridge"),
|
||||
("картош", 22, "картошка", "potatoes"),
|
||||
("куриц", 35, "курица", "chicken"),
|
||||
]
|
||||
|
||||
|
||||
def match_recipe(phrase: str):
|
||||
"""Return (minutes, ru_label) for the first matching recipe substring,
|
||||
or None if nothing in the phrase matches. Exposed for tests."""
|
||||
p = (phrase or "").lower()
|
||||
for sub, mins, ru, _en in _RECIPES:
|
||||
if sub in p:
|
||||
return mins, ru
|
||||
return None
|
||||
|
||||
|
||||
def do_cooking(action=None, voice=None) -> None:
|
||||
"""Detect dish in the voice phrase, schedule a 'ready' reminder for the
|
||||
right number of minutes through the persistent scheduler.
|
||||
|
||||
Signature matches the rest of the `do_*` handlers: (action_dict, voice).
|
||||
The recipe match looks at `voice` (the raw phrase the user said)."""
|
||||
phrase = voice or ""
|
||||
match = match_recipe(phrase)
|
||||
if match is None:
|
||||
_say("Не понял, что ты готовишь, сэр.")
|
||||
return
|
||||
minutes, label = match
|
||||
spec = f"in {minutes} minutes"
|
||||
try:
|
||||
sched = scheduler_store.parse_schedule(spec)
|
||||
except ValueError as e:
|
||||
_say(f"Не получилось распарсить расписание: {e}")
|
||||
return
|
||||
task = {
|
||||
"name": f"Кухня: {label}",
|
||||
"schedule": sched,
|
||||
"action": {"type": "speak", "text": f"Сэр, {label} готов."},
|
||||
}
|
||||
scheduler_store.add(task)
|
||||
_say(f"Хорошо, напомню через {minutes} минут когда {label} будет готов.")
|
||||
|
||||
|
||||
# ─── Leftoff recap ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def do_leftoff(action=None, voice=None, last_assistant_reply: Optional[str] = None) -> None:
|
||||
"""Stitch a recap from independent sources. Pure-offline.
|
||||
|
||||
`last_assistant_reply` is callable from tests to pin the third source;
|
||||
the production dispatcher passes None (main.py keeps its own LLM history)."""
|
||||
parts = []
|
||||
|
||||
facts = memory_store.all_facts()
|
||||
if facts:
|
||||
facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True)[:3]
|
||||
items = [f"{f['key']} — {(f.get('value') or '')[:50]}" for f in facts]
|
||||
parts.append("Из памяти: " + "; ".join(items) + ".")
|
||||
|
||||
tasks = scheduler_store.list_all()
|
||||
if tasks:
|
||||
first_name = tasks[0].get("name") or "task"
|
||||
parts.append(f"В расписании {len(tasks)}, ближайшее: {first_name}.")
|
||||
|
||||
if last_assistant_reply:
|
||||
snippet = " ".join(last_assistant_reply.split())[:120]
|
||||
parts.append("Я говорил: " + snippet)
|
||||
|
||||
if not parts:
|
||||
_say("Ничего недавно не происходило, сэр.")
|
||||
return
|
||||
_say(" ".join(parts))
|
||||
|
||||
|
||||
# ─── Trivia ───────────────────────────────────────────────────────────────
|
||||
|
||||
_TRIVIA_RU = [
|
||||
"Первый костюм Старк собрал в пещере в Афганистане. Десять рабочих недель, ноль вентиляции.",
|
||||
"Дуговый реактор изначально питал электромагнит, удерживающий осколки шрапнели у самого сердца.",
|
||||
"Палладий в первом реакторе медленно отравлял Старка. Решением стал новый, синтезированный отцом, элемент.",
|
||||
"Mark I весил около 200 килограммов и летал лишь однажды — на побег из плена.",
|
||||
"Mark III выкрашен в красно-золотой потому, что чистое золото оказалось мягче, чем хотелось.",
|
||||
"Mark V — складной костюм в чемодане. Идеальное оружие на званый ужин.",
|
||||
"Hulkbuster, Mark XLIV, был запущен совместно с Брюсом Беннером — на случай, если Халк выйдет из берегов.",
|
||||
"Я, сэр, появился в фильме «Железный человек» 2008 года. Меня озвучил Пол Беттани.",
|
||||
"Имя J.A.R.V.I.S. — отсылка к Эдвину Джарвису, дворецкому отца Тони, Говарда Старка.",
|
||||
"После событий «Эры Альтрона» программа J.A.R.V.I.S. была интегрирована в Vision.",
|
||||
]
|
||||
|
||||
_TRIVIA_EN = [
|
||||
"Stark built the first suit in a cave in Afghanistan. Ten weeks, no ventilation.",
|
||||
"The arc reactor originally powered an electromagnet keeping shrapnel away from his heart.",
|
||||
"Palladium in the first reactor was slowly poisoning him. The cure was a new element his father had synthesised.",
|
||||
"Mark I weighed roughly 200 kilograms and flew exactly once — for the escape.",
|
||||
"Mark III is red-and-gold because pure gold turned out to be too soft.",
|
||||
"Mark V folds into a briefcase. Useful at galas.",
|
||||
"I, sir, first appeared in Iron Man (2008). Paul Bettany voiced me.",
|
||||
"The name J.A.R.V.I.S. nods to Edwin Jarvis, butler to Tony's father Howard.",
|
||||
]
|
||||
|
||||
|
||||
def do_trivia(action=None, voice=None, lang: str = "ru") -> None:
|
||||
pool = _TRIVIA_EN if lang == "en" else _TRIVIA_RU
|
||||
_say(random.choice(pool))
|
||||
|
||||
|
||||
# ─── Routines ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def do_good_night(action=None, voice=None) -> None:
|
||||
"""Switch to 'sleep' profile, cancel one-shot timers, speak goodnight."""
|
||||
try:
|
||||
profiles_store.set_active("sleep")
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
cancelled = 0
|
||||
for task in scheduler_store.list_all():
|
||||
name = task.get("name") or ""
|
||||
if name.startswith(("Таймер:", "Timer:", "Кухня:", "Cooking:")):
|
||||
if scheduler_store.remove(task.get("id", "")):
|
||||
cancelled += 1
|
||||
|
||||
lines = [
|
||||
"Спокойной ночи, сэр. Я остаюсь на связи.",
|
||||
"Доброй ночи, сэр. Системы переведены в тихий режим.",
|
||||
"Спите крепко, сэр. Утром доложу о всём важном.",
|
||||
"Хорошо, сэр. Не отвлекаю до утра.",
|
||||
]
|
||||
line = random.choice(lines)
|
||||
if cancelled > 0:
|
||||
line += f" Отменил {cancelled} одноразовых таймеров."
|
||||
_say(line)
|
||||
|
||||
|
||||
def do_good_morning(action=None, voice=None) -> None:
|
||||
try:
|
||||
profiles_store.set_active("default")
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
n_tasks = len(scheduler_store.list_all())
|
||||
lines = [
|
||||
"Доброе утро, сэр. Системы в порядке.",
|
||||
"Доброе утро. Готов к новому дню.",
|
||||
"С пробуждением, сэр. День в норме.",
|
||||
"Доброе утро, сэр. Кофе сам себя не сварит.",
|
||||
]
|
||||
line = random.choice(lines)
|
||||
if n_tasks > 0:
|
||||
line += f" В расписании {n_tasks}, скажите 'покажи расписание', чтобы услышать."
|
||||
else:
|
||||
line += " Расписание чистое."
|
||||
_say(line)
|
||||
|
||||
|
||||
def do_coffee_break(action=None, voice=None) -> None:
|
||||
"""Schedule a 5-min check-in reminder. Banter pause is Rust-only — Python
|
||||
side doesn't have an idle-banter equivalent yet."""
|
||||
try:
|
||||
sched = scheduler_store.parse_schedule("in 5 minutes")
|
||||
except ValueError:
|
||||
_say("Не получилось поставить чек-ин.")
|
||||
return
|
||||
scheduler_store.add({
|
||||
"name": "Чек-ин после кофе",
|
||||
"schedule": sched,
|
||||
"action": {"type": "speak", "text": "Сэр, прошло пять минут. Вы вернулись?"},
|
||||
})
|
||||
lines = [
|
||||
"Хорошо, сэр. Возвращайтесь через пять.",
|
||||
"Тихо в эфире пять минут. Удачно вам.",
|
||||
"Пять минут — норма для кофе, не более.",
|
||||
]
|
||||
_say(random.choice(lines))
|
||||
Loading…
Add table
Add a link
Reference in a new issue