"""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}), }