Replay now matches the robots and no longer cuts words: - read the turn to turnComplete, not generationComplete, and drain the socket before each send; breaking early truncated every sentence and left frames that the next turn mis-read as its own reply - accept a take only if the model's own transcript covers the text AND the audio is long enough to contain it (the transcript reports the full text even for a 0.8s clip) - pitch gate: reject an off-tone take and re-ask, per voice, using a pure-Python F0 estimator (no numpy on the host) - continuation: speak the words a voice skipped instead of retrying a line it stops on deterministically - fresh Live session per replay; delivery drifts as turns accumulate Live Gemini tab: browser talks to Gemini directly (the reverse proxy cannot upgrade a WebSocket), with a persona library - named personas, per-robot selection, built-ins that cannot be overwritten. Dashboard: records search + voice filter, log panel falls back to polling, sign-in history with CSV/JSON export, and JS errors now show on the page instead of silently blanking a tab.
166 lines
7.0 KiB
Python
166 lines
7.0 KiB
Python
"""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": {"<id>": {"name": str, "text": str, "updated": str}},
|
|
"active": {"<voiceName>": "<id>"}
|
|
}
|
|
|
|
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
|