"""P1-specific dashboard routes (mounted at /api/p1 by app_p1.py). First-class P1 settings that REUSE Sanad's canonical logic (no fork) and add the one thing the base routes don't: applying a change to the LIVE Gemini session immediately by restarting the voice subprocess (the child reads the API key + persona at spawn time). /api/p1/api-key GET masked status | POST set/update + live-restart /api/p1/persona GET current persona+rules | POST update persona + live-restart /api/p1/settings one-shot view (api-key + persona + language + audio + live) Kept Python-3.8 compatible. """ from __future__ import annotations import asyncio import base64 import os from fastapi import APIRouter, HTTPException from Project.Sanad.core.logger import get_logger from Project.Sanad.dashboard.routes import voice as _voice # reuse api-key logic from Project.Sanad.dashboard.routes import prompt as _prompt # reuse persona logic from Project.Sanad.dashboard.routes import typed_replay as _tr # reuse local TTS say from sanad_pkg.bus import bus # Bind request models as module-level names so FastAPI resolves body annotations # cleanly under `from __future__ import annotations` (dotted forward-refs are # version-fragile). ApiKeyPayload = _voice.ApiKeyPayload PromptUpdate = _prompt.PromptUpdate SayPayload = _tr.SayPayload log = get_logger("pkg1.routes") router = APIRouter() async def _restart_live_if_running() -> bool: """Restart the live Gemini subprocess (if running) so a new key/persona takes effect immediately. Returns True if it was restarted.""" try: from Project.Sanad.main import live_sub is_running = getattr(live_sub, "is_running", None) if live_sub is None or not callable(is_running) or not is_running(): return False try: live_sub.stop() except Exception: log.exception("live_sub.stop() failed") try: await asyncio.to_thread(live_sub.start) return True except Exception: log.exception("live_sub.start() failed") return False except Exception: log.exception("could not restart live subprocess") return False # ─────────────────────────── Gemini API key ─────────────────────────── def _persist_and_hotswap_key(key: str) -> None: """Persist gemini.api_key to data/motions/config.json (empty string => remove) and hot-swap the in-memory globals so it takes effect without a restart. Patches BOTH Project.Sanad.config and gemini.client (each binds its own ref).""" from Project.Sanad.config import load_config, save_config import Project.Sanad.config as _cfg_mod cfg = load_config() or {} g = cfg.get("gemini") if isinstance(cfg.get("gemini"), dict) else {} if key: g["api_key"] = key else: g.pop("api_key", None) cfg["gemini"] = g save_config(cfg) _cfg_mod.GEMINI_API_KEY = key try: import Project.Sanad.gemini.client as _gc _gc.GEMINI_API_KEY = key except Exception: log.exception("could not patch gemini.client.GEMINI_API_KEY") async def _disconnect_voice(): try: from Project.Sanad.main import voice_client if voice_client is not None and getattr(voice_client, "connected", False): await voice_client.disconnect() except Exception: log.exception("voice_client disconnect failed") @router.get("/api-key") async def p1_get_api_key(): """Masked current key + where it came from (delegates to the voice route).""" return await _voice.get_api_key() @router.post("/api-key") async def p1_set_api_key(payload: ApiKeyPayload): """ADD / update the Gemini API key. Relaxed validation — accepts any reasonable key (AIza… standard keys AND AQ.… / ephemeral tokens), not just AIza. Persists + hot-swaps + restarts the live session so it applies now.""" key = (payload.api_key or "").strip() if len(key) < 10: raise HTTPException(400, "API key looks too short (paste the full key).") _persist_and_hotswap_key(key) await _disconnect_voice() restarted = await _restart_live_if_running() return { "ok": True, "masked": _voice._mask_api_key(key), "source": "config_file", "live_subprocess_restarted": restarted, "message": "API key added" + (" and applied (live session restarted)." if restarted else " — start the session to use it."), } @router.post("/api-key/delete") async def p1_delete_api_key(): """DELETE the Gemini API key — clears it from data/motions/config.json and in-memory. Conversation stops until a new key is added. (If config.py has a hardcoded fallback, that re-applies on the next process restart.)""" _persist_and_hotswap_key("") await _disconnect_voice() restarted = await _restart_live_if_running() return { "ok": True, "deleted": True, "live_subprocess_restarted": restarted, "message": "API key deleted. Add a new key to re-enable conversation.", } # ─────────────────────────── Robot persona ─────────────────────────── @router.get("/persona") async def p1_get_persona(): """Current persona system prompt + parsed rules + file paths.""" return await _prompt.get_prompt() @router.post("/persona") async def p1_set_persona(payload: PromptUpdate): """Change the robot persona — write scripts/sanad_script.txt (canonical prompt logic) and restart the live session so it speaks with the new persona immediately. The persona is also where you steer language/dialect.""" result = await _prompt.update_prompt(payload) # atomic write to sanad_script.txt restarted = await _restart_live_if_running() result["live_subprocess_restarted"] = restarted result["message"] = ( "Persona saved and applied — live session restarted." if restarted else "Persona saved. Start (or restart) the live session to use the new persona." ) return result # ─────────────────────────── say a line ─────────────────────────── @router.post("/say") async def p1_say(payload: SayPayload): """Speak a typed line. Standalone (no bus) → play locally via Sanad's typed-replay. Multi-package (SANAD_BUS_ADDR set) → synth via Gemini and hand the PCM to the hwbroker `speak.request`, so it plays under the audio-lock (refused/queued while the live conversation is speaking).""" text = (payload.text or "").strip() if not text: raise HTTPException(400, "text cannot be empty") if not os.environ.get("SANAD_BUS_ADDR"): # standalone — no contention, play directly (reuse canonical typed-replay) return await _tr.say(payload) # multi-package — route audio output through the hwbroker audio-lock from Project.Sanad.main import voice_client if voice_client is None: raise HTTPException(503, "voice client unavailable") if not getattr(voice_client, "connected", False): try: await voice_client.connect() except Exception as exc: raise HTTPException(503, "Gemini connect failed: %s" % exc) try: audio, _parts = await voice_client.send_text(text, owner="p1_say") except Exception as exc: raise HTTPException(502, "Gemini error: %s" % exc) if not audio: return {"ok": False, "routed": "hwbroker", "reason": "no audio produced"} bus.emit_sync("speak.request", owner="p1", pcm_b64=base64.b64encode(audio).decode("ascii"), rate=24000, channels=1, sampwidth=2) return {"ok": True, "routed": "hwbroker (audio-lock)"} # ─────────────────────────── logs ─────────────────────────── @router.post("/logs/delete") async def p1_delete_logs(): """Delete all log files on the robot. Active .log files are truncated (so the live logger keeps a valid handle); rotated/snapshot/bundle files are removed.""" from Project.Sanad.config import LOGS_DIR cleared = [] try: for p in sorted(LOGS_DIR.glob("*.log*")): try: if p.name.endswith(".log") and "_snapshot_" not in p.name: open(p, "w").close() # truncate active log else: p.unlink() # remove rotated/snapshot/bundle cleared.append(p.name) except Exception: log.exception("could not clear log %s", p.name) except Exception: log.exception("delete logs failed") return {"ok": True, "count": len(cleared), "cleared": cleared} # ─────────────────────────── combined view ─────────────────────────── @router.get("/settings") async def p1_settings(): """One-shot P1 settings: api-key status + persona + language + audio + live.""" key_status = await _voice.get_api_key() persona = "" try: persona = _prompt._load_system_prompt() except Exception: log.exception("could not load persona") live_running = False try: from Project.Sanad.main import live_sub is_running = getattr(live_sub, "is_running", None) live_running = bool(live_sub is not None and callable(is_running) and is_running()) except Exception: pass return { "package": "P1", "api_key": key_status, "persona_preview": (persona[:400] + ("…" if len(persona) > 400 else "")), "persona_chars": len(persona), "language": os.environ.get("SANAD_LANGUAGE", ""), "audio_profile": os.environ.get("SANAD_AUDIO_PROFILE", "builtin"), "live_running": live_running, }