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.
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
"""Prompt management — view, edit, reload system prompts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from Project.Sanad.config import SCRIPTS_DIR
|
|
from Project.Sanad.core.config_loader import section as _cfg_section
|
|
from Project.Sanad.core.logger import get_logger
|
|
from Project.Sanad.dashboard.routes._safe_io import (
|
|
atomic_write_text, MAX_UPLOAD_BYTES,
|
|
)
|
|
|
|
router = APIRouter()
|
|
log = get_logger("prompt_route")
|
|
|
|
# Filenames — SINGLE SOURCE in core.script_files
|
|
_SCRIPTS = _cfg_section("core", "script_files")
|
|
SCRIPT_PROMPT_PATH = SCRIPTS_DIR / _SCRIPTS.get("persona", "sanad_script.txt")
|
|
RULE_PROMPT_PATH = SCRIPTS_DIR / _SCRIPTS.get("rules", "sanad_rule.txt")
|
|
MAX_PROMPT_BYTES = MAX_UPLOAD_BYTES
|
|
|
|
# Default system prompt — SINGLE SOURCE in core.gemini_defaults
|
|
DEFAULT_SYSTEM_PROMPT = _cfg_section("core", "gemini_defaults").get(
|
|
"default_system_prompt",
|
|
"You are Sanad (Bousandah), a wise and friendly Emirati assistant. "
|
|
"Speak strictly in the UAE dialect (Khaleeji). "
|
|
"Be helpful, concise, and use local greetings like 'Marhaba' and 'Ya Khoy'."
|
|
)
|
|
|
|
|
|
def _load_system_prompt() -> str:
|
|
try:
|
|
content = SCRIPT_PROMPT_PATH.read_text(encoding="utf-8-sig").strip()
|
|
if content:
|
|
return content
|
|
except FileNotFoundError:
|
|
pass
|
|
return DEFAULT_SYSTEM_PROMPT
|
|
|
|
|
|
def _load_rule_prompts() -> dict[str, str]:
|
|
result = {"system_prompt": "", "replay_prompt": ""}
|
|
try:
|
|
content = RULE_PROMPT_PATH.read_text(encoding="utf-8-sig").strip()
|
|
sections: dict[str, list[str]] = {}
|
|
current = None
|
|
for line in content.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith("[") and stripped.endswith("]"):
|
|
current = stripped[1:-1].strip()
|
|
sections[current] = []
|
|
elif current is not None:
|
|
sections[current].append(line.rstrip())
|
|
result["system_prompt"] = "\n".join(sections.get("SYSTEM_PROMPT", [])).strip()
|
|
result["replay_prompt"] = "\n".join(sections.get("REPLAY_SYSTEM_PROMPT", [])).strip()
|
|
except FileNotFoundError:
|
|
pass
|
|
if not result["system_prompt"]:
|
|
result["system_prompt"] = _load_system_prompt()
|
|
return result
|
|
|
|
|
|
@router.get("/")
|
|
async def get_prompt():
|
|
return {
|
|
"script_path": str(SCRIPT_PROMPT_PATH),
|
|
"rule_path": str(RULE_PROMPT_PATH),
|
|
"system_prompt": _load_system_prompt(),
|
|
"rules": _load_rule_prompts(),
|
|
}
|
|
|
|
|
|
class PromptUpdate(BaseModel):
|
|
content: str
|
|
|
|
|
|
@router.post("/update")
|
|
async def update_prompt(payload: PromptUpdate):
|
|
if len(payload.content.encode("utf-8")) > MAX_PROMPT_BYTES:
|
|
raise HTTPException(413, f"Prompt too large (max {MAX_PROMPT_BYTES} bytes).")
|
|
try:
|
|
SCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
atomic_write_text(SCRIPT_PROMPT_PATH, payload.content.rstrip() + "\n")
|
|
except OSError as exc:
|
|
raise HTTPException(500, f"Could not write prompt: {exc}")
|
|
return {"ok": True, "path": str(SCRIPT_PROMPT_PATH), "length": len(payload.content)}
|
|
|
|
|
|
@router.post("/reload")
|
|
async def reload_prompts():
|
|
rules = _load_rule_prompts()
|
|
return {
|
|
"ok": True,
|
|
"system_prompt": rules["system_prompt"],
|
|
"replay_prompt": rules["replay_prompt"],
|
|
"script_path": str(SCRIPT_PROMPT_PATH),
|
|
"rule_path": str(RULE_PROMPT_PATH),
|
|
}
|
|
|
|
|
|
# ── Voice replay rule — the [REPLAY_SYSTEM_PROMPT] that shapes the spoken voice ──
|
|
|
|
class RulePayload(BaseModel):
|
|
content: str
|
|
|
|
|
|
@router.get("/rule")
|
|
async def get_rule():
|
|
"""The current voice replay rule (how typed text is spoken: accent, verbatim, gender)."""
|
|
return {"content": _load_rule_prompts().get("replay_prompt", ""),
|
|
"rule_path": str(RULE_PROMPT_PATH)}
|
|
|
|
|
|
@router.post("/rule")
|
|
async def update_rule(payload: RulePayload):
|
|
"""Save the replay rule to sanad_rule.txt AND apply it live (hot-swap + reconnect)."""
|
|
content = payload.content.strip()
|
|
if not content:
|
|
raise HTTPException(400, "Rule cannot be empty.")
|
|
if len(content.encode("utf-8")) > MAX_PROMPT_BYTES:
|
|
raise HTTPException(413, f"Rule too large (max {MAX_PROMPT_BYTES} bytes).")
|
|
try:
|
|
SCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
atomic_write_text(
|
|
RULE_PROMPT_PATH, "[REPLAY_SYSTEM_PROMPT]\n" + content.rstrip() + "\n")
|
|
except OSError as exc:
|
|
raise HTTPException(500, f"Could not write rule: {exc}")
|
|
# Apply immediately: hot-swap the live voice_client's system prompt and drop the
|
|
# session so the next Generate & Play reconnects with the new rule.
|
|
applied = False
|
|
try:
|
|
from Project.Sanad.main import voice_client
|
|
if voice_client is not None:
|
|
voice_client.system_prompt = content
|
|
if getattr(voice_client, "connected", False):
|
|
await voice_client.disconnect()
|
|
applied = True
|
|
except Exception:
|
|
log.exception("could not hot-swap the voice rule")
|
|
return {"ok": True, "applied": applied,
|
|
"message": ("Rule saved and applied — takes effect on your next Generate & Play."
|
|
if applied else "Rule saved (restart to apply).")}
|