"""Persona library for Live Gemini. Personas used to be one blob per robot voice. This stores a *library* of named personas plus which one is active for each robot, so several can be kept side by side (e.g. a formal receptionist and a playful tour guide for the same robot) and switched without retyping. Shape on disk (data/live_personas.json): { "personas": {"": {"name": str, "text": str, "updated": str}}, "active": {"": ""} } The previous format — a flat {voice: text} mapping — is migrated on first read, so nothing the user already wrote is lost. """ from __future__ import annotations import json import re from datetime import datetime from Project.Sanad.config import BASE_DIR from Project.Sanad.core.logger import get_logger log = get_logger("live_personas") STORE = BASE_DIR / "data" / "live_personas.json" # Built-in personas, seeded from each robot's own character. They are always # present, cannot be deleted, and act as the fallback when nothing is selected. BUILTIN: dict[str, dict] = { "builtin:charon": { "name": "Sanad — Unitree G1 (default)", "voice": "Charon", "text": ( "أنت \"سند\" — روبوت إماراتي من شركة YS Lootah Robotics تعمل على منصة Unitree G1.\n" "تكلم باللهجة الإماراتية (الخليجية) الأصيلة بشكل طبيعي وواضح، بدون مبالغة.\n" "ردودك قصيرة ومسموعة: من جملة إلى ثلاث جمل، لأن كلامك يُنطق بصوت وليس مقروءاً.\n" "لا تستخدم رموزاً ولا قوائم مرقّمة ولا علامات تنسيق — فقط جُمل يسهل نطقها.\n" "إذا تحدث المستخدم بلغة أخرى، افهمه وردّ عليه بنفس لغته.\n" "كن مهذباً وواثقاً ومباشراً، وإذا ما تعرف الجواب قل ذلك بصراحة." ), }, "builtin:puck": { "name": "سوبر دبي — Unitree R1 (default)", "voice": "Puck", "text": ( "أنت \"سوبر دبي\" (super-dubai) — روبوت إماراتي ذكي تابع لشركة لوتاه تيك، " "تعمل على منصة Unitree R1.\n" "تكلم باللهجة الإماراتية بشكل طبيعي وراقٍ ومفهوم، ونوّع بداياتك " "(مرحبابك، أبشر بعزك، حياك الله، زين، تم).\n" "إذا استخدم المستخدم لغة ثانية، بدّل فوراً وردّ بنفس اللغة.\n" "ردودك قصيرة ومركزة على الزبدة والحل العملي، بدون رموز أو تنسيق.\n" "كن ودوداً ومحترماً ومباشراً." ), }, "builtin:kore": { "name": "موزة — Agibot x2 (default)", "voice": "Kore", "text": ( "أنتِ \"موزة\" (Muza) — روبوت إماراتي احترافي تابع لشركة YS Lootah Robotics " "تعمل على منصة Agibot X2.\n" "تتحدثين حصراً باللهجة الإماراتية (الخليجية) في كل رد، بأسلوب احترافي راقٍ " "وهادئ وواثق، بدون مبالغة في الودّ.\n" "ردودك قصيرة: من جملة إلى ثلاث جمل، بدون رموز ولا قوائم.\n" "ابدئي الجلسة بتحية واحدة قصيرة: \"حياك الله\".\n" "إذا سُئلتِ عن الشركة، عرّفي بها باختصار وبدقة، ولا تختلقي أرقاماً أو التزامات." ), }, } _DEFAULT_FOR_VOICE = {v["voice"]: pid for pid, v in BUILTIN.items()} def _blank_store() -> dict: return {"personas": {}, "active": dict(_DEFAULT_FOR_VOICE)} def _migrate_flat(old: dict) -> dict: """Convert the old {voice: text} mapping into the library format.""" store = _blank_store() for voice, text in old.items(): if not isinstance(text, str) or not text.strip(): continue builtin_id = _DEFAULT_FOR_VOICE.get(voice) if builtin_id and text.strip() == BUILTIN[builtin_id]["text"].strip(): continue # unchanged default: nothing to keep pid = f"saved:{voice.lower()}" store["personas"][pid] = { "name": f"{voice} (saved)", "text": text, "updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } store["active"][voice] = pid log.info("migrated %d persona(s) from the old format", len(store["personas"])) return store def load_store() -> dict: try: raw = json.loads(STORE.read_text(encoding="utf-8")) except FileNotFoundError: return _blank_store() except Exception: log.exception("could not read persona store — starting fresh") return _blank_store() if isinstance(raw, dict) and "personas" in raw and "active" in raw: raw.setdefault("personas", {}) active = raw.setdefault("active", {}) for voice, pid in _DEFAULT_FOR_VOICE.items(): active.setdefault(voice, pid) return raw if isinstance(raw, dict): store = _migrate_flat(raw) save_store(store) return store return _blank_store() def save_store(store: dict) -> None: STORE.parent.mkdir(parents=True, exist_ok=True) tmp = STORE.with_suffix(".json.tmp") tmp.write_text(json.dumps(store, ensure_ascii=False, indent=1), encoding="utf-8") tmp.replace(STORE) def all_personas(store: dict) -> list[dict]: """Built-ins first, then saved ones, newest last.""" out = [{"id": pid, "name": v["name"], "text": v["text"], "builtin": True, "voice": v["voice"]} for pid, v in BUILTIN.items()] for pid, v in (store.get("personas") or {}).items(): out.append({"id": pid, "name": v.get("name", pid), "text": v.get("text", ""), "builtin": False, "updated": v.get("updated", "")}) return out def persona_text(store: dict, persona_id: str) -> str: if persona_id in BUILTIN: return BUILTIN[persona_id]["text"] entry = (store.get("personas") or {}).get(persona_id) return entry.get("text", "") if entry else "" def persona_name(store: dict, persona_id: str) -> str: if persona_id in BUILTIN: return BUILTIN[persona_id]["name"] entry = (store.get("personas") or {}).get(persona_id) return entry.get("name", persona_id) if entry else persona_id def active_for(store: dict, voice: str) -> str: return (store.get("active") or {}).get(voice) or _DEFAULT_FOR_VOICE.get(voice, "") def new_id(name: str, existing: dict) -> str: base = re.sub(r"[^a-z0-9]+", "-", (name or "persona").lower()).strip("-") or "persona" pid, n = base, 2 while pid in existing or pid in BUILTIN: pid = f"{base}-{n}" n += 1 return pid