"""Skill Registry — maps audio files to motion commands and callback functions. A "skill" is a named unit that ties together: - An audio clip (e.g. recordings/audio/intro.wav) - A motion file (e.g. data/motions/wave.jsonl) — optional - A callback (e.g. "motion.wave_hand") — resolved at runtime The registry is persisted in data/skills.json and can be edited via the dashboard or programmatically through the Brain. Skill entry schema: { "id": "intro_greeting", "audio_file": "recordings/audio/intro.wav", "motion_file": "data/motions/right_hand_up.jsonl", "callback": "motion.trigger:wave_hand", "sync_mode": "parallel", # parallel | audio_first | motion_first "enabled": true, "description": "Wave hand while playing intro audio" } """ from __future__ import annotations import json import os import tempfile import threading import uuid from dataclasses import dataclass, field, asdict from pathlib import Path from typing import Any from Project.Sanad.config import SKILLS_FILE from Project.Sanad.core.logger import get_logger log = get_logger("skill_registry") @dataclass class Skill: id: str audio_file: str = "" motion_file: str = "" callback: str = "" sync_mode: str = "parallel" enabled: bool = True description: str = "" meta: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: return asdict(self) @classmethod def from_dict(cls, data: dict[str, Any]) -> Skill: known = {f.name for f in cls.__dataclass_fields__.values()} filtered = {k: v for k, v in data.items() if k in known} return cls(**filtered) class SkillRegistry: """Thread-safe, JSON-backed registry of skills.""" def __init__(self, path: Path = SKILLS_FILE): self._path = path self._lock = threading.Lock() self._skills: dict[str, Skill] = {} self._load() # -- persistence -- def _load(self): if not self._path.exists(): self._skills = {} return try: with open(self._path, "r", encoding="utf-8") as f: payload = json.load(f) for entry in payload.get("skills", []): skill = Skill.from_dict(entry) self._skills[skill.id] = skill log.info("Loaded %d skills from %s", len(self._skills), self._path) except Exception as exc: log.warning("Could not load skills: %s", exc) # Backup corrupt file rather than silently nuking try: self._path.rename(self._path.with_suffix(".json.corrupt")) log.warning("Backed up corrupt skills to %s.corrupt", self._path) except OSError: pass self._skills = {} _VALID_SYNC_MODES = {"parallel", "audio_first", "motion_first"} def _save(self): self._path.parent.mkdir(parents=True, exist_ok=True) payload = { "version": 1, "total": len(self._skills), "skills": [s.to_dict() for s in self._skills.values()], } # Atomic write: tempfile + os.replace fd, tmp = tempfile.mkstemp( prefix=f".{self._path.name}.", suffix=".tmp", dir=str(self._path.parent), ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) os.replace(tmp, self._path) except Exception: try: os.unlink(tmp) except OSError: pass raise # -- CRUD -- def list_skills(self) -> list[dict[str, Any]]: with self._lock: return [s.to_dict() for s in self._skills.values()] def get(self, skill_id: str) -> Skill | None: with self._lock: return self._skills.get(skill_id) def add(self, skill: Skill) -> Skill: if skill.sync_mode not in self._VALID_SYNC_MODES: raise ValueError( f"Invalid sync_mode '{skill.sync_mode}' (allowed: {sorted(self._VALID_SYNC_MODES)})" ) with self._lock: if not skill.id: skill.id = uuid.uuid4().hex[:12] elif skill.id in self._skills: raise ValueError(f"Skill id already exists: {skill.id}") self._skills[skill.id] = skill self._save() log.info("Added skill %s (%s)", skill.id, skill.description) return skill def update(self, skill_id: str, updates: dict[str, Any]) -> Skill | None: with self._lock: existing = self._skills.get(skill_id) if existing is None: return None if "sync_mode" in updates and updates["sync_mode"] not in self._VALID_SYNC_MODES: raise ValueError( f"Invalid sync_mode '{updates['sync_mode']}'" ) for key, value in updates.items(): if hasattr(existing, key) and key != "id": setattr(existing, key, value) self._save() log.info("Updated skill %s", skill_id) return existing def delete(self, skill_id: str) -> dict[str, Any] | None: with self._lock: skill = self._skills.pop(skill_id, None) if skill is None: return None self._save() log.info("Deleted skill %s", skill_id) return skill.to_dict() def find_by_audio(self, audio_file: str) -> list[Skill]: """Find all skills linked to a given audio file.""" with self._lock: return [s for s in self._skills.values() if s.audio_file == audio_file and s.enabled] def find_by_callback(self, callback: str) -> list[Skill]: with self._lock: return [s for s in self._skills.values() if s.callback == callback and s.enabled]