Sanad_lite/dashboard/routes/live_personas.py
Sanad Lite d9b2d5427f Voice fidelity, Live Gemini tab, and dashboard fixes
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.
2026-09-02 22:56:18 +04:00

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