176 lines
5.9 KiB
Python
176 lines
5.9 KiB
Python
"""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]
|