141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""Wake-phrase registry.
|
|
|
|
Maps wake phrases (e.g. "hey sanad") → skill / callback IDs. Phrases are
|
|
persisted in data/wake_phrases.json so dashboard edits survive restart.
|
|
|
|
This module is deliberately lightweight — it only *stores* the mapping.
|
|
Matching a transcript against the registered phrases is done in
|
|
`voice/text_utils.match_phrase`, and the actual trigger is orchestrated
|
|
by `core/brain.py`'s skill registry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from Project.Sanad.core.logger import get_logger
|
|
from Project.Sanad.config import DATA_DIR
|
|
|
|
log = get_logger("wake_phrase_manager")
|
|
|
|
STATE_PATH = DATA_DIR / "wake_phrases.json"
|
|
|
|
|
|
@dataclass
|
|
class WakePhrase:
|
|
"""A single wake phrase → action mapping."""
|
|
phrase: str
|
|
action_id: str
|
|
enabled: bool = True
|
|
|
|
def normalized(self) -> str:
|
|
return self.phrase.strip().lower()
|
|
|
|
|
|
class WakePhraseManager:
|
|
"""Thread-safe in-memory store for wake phrases, persisted to disk."""
|
|
|
|
def __init__(self, path: Path = STATE_PATH):
|
|
self.path = Path(path)
|
|
self._lock = threading.Lock()
|
|
self._phrases: list[WakePhrase] = []
|
|
self._load()
|
|
|
|
# ── persistence ──────────────────────────────────────────────────
|
|
def _load(self) -> None:
|
|
if not self.path.exists():
|
|
return
|
|
try:
|
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
log.warning("wake_phrases.json unreadable: %s", exc)
|
|
return
|
|
with self._lock:
|
|
self._phrases = [
|
|
WakePhrase(**{k: v for k, v in d.items() if k in WakePhrase.__annotations__})
|
|
for d in data if isinstance(d, dict) and "phrase" in d
|
|
]
|
|
log.info("Loaded %d wake phrase(s)", len(self._phrases))
|
|
|
|
def _save(self) -> None:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
tmp.write_text(
|
|
json.dumps([asdict(p) for p in self._phrases], indent=2, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
tmp.replace(self.path)
|
|
|
|
# ── CRUD ─────────────────────────────────────────────────────────
|
|
def list(self) -> list[dict[str, Any]]:
|
|
with self._lock:
|
|
return [asdict(p) for p in self._phrases]
|
|
|
|
def add(self, phrase: str, action_id: str) -> dict[str, Any]:
|
|
norm = phrase.strip().lower()
|
|
if not norm:
|
|
raise ValueError("phrase cannot be empty")
|
|
with self._lock:
|
|
for p in self._phrases:
|
|
if p.normalized() == norm and p.action_id == action_id:
|
|
return asdict(p)
|
|
wp = WakePhrase(phrase=phrase.strip(), action_id=action_id)
|
|
self._phrases.append(wp)
|
|
self._save()
|
|
return asdict(wp)
|
|
|
|
def remove(self, phrase: str, action_id: str | None = None) -> bool:
|
|
norm = phrase.strip().lower()
|
|
with self._lock:
|
|
before = len(self._phrases)
|
|
self._phrases = [
|
|
p for p in self._phrases
|
|
if not (p.normalized() == norm and
|
|
(action_id is None or p.action_id == action_id))
|
|
]
|
|
removed = before != len(self._phrases)
|
|
if removed:
|
|
self._save()
|
|
return removed
|
|
|
|
def set_enabled(self, phrase: str, action_id: str, enabled: bool) -> bool:
|
|
norm = phrase.strip().lower()
|
|
with self._lock:
|
|
for p in self._phrases:
|
|
if p.normalized() == norm and p.action_id == action_id:
|
|
p.enabled = enabled
|
|
self._save()
|
|
return True
|
|
return False
|
|
|
|
def for_action(self, action_id: str) -> set[str]:
|
|
"""Return all enabled phrases registered for an action."""
|
|
with self._lock:
|
|
return {
|
|
p.normalized() for p in self._phrases
|
|
if p.action_id == action_id and p.enabled
|
|
}
|
|
|
|
def action_phrase_map(self) -> dict[str, set[str]]:
|
|
"""Return {action_id: {phrases}} for all enabled entries."""
|
|
result: dict[str, set[str]] = {}
|
|
with self._lock:
|
|
for p in self._phrases:
|
|
if p.enabled:
|
|
result.setdefault(p.action_id, set()).add(p.normalized())
|
|
return result
|
|
|
|
# ── status ───────────────────────────────────────────────────────
|
|
def status(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
enabled = sum(1 for p in self._phrases if p.enabled)
|
|
return {
|
|
"total": len(self._phrases),
|
|
"enabled": enabled,
|
|
"actions": sorted({p.action_id for p in self._phrases}),
|
|
}
|