dsh: speak tool — announce over house speakers via Jervis (function-calling gate, zero latency for normal chat)

This commit is contained in:
2026-09-19 16:53:24 +10:00
parent 438445115f
commit 1d3e2b6ab7

View File

@@ -42,7 +42,13 @@ SYSTEM_PROMPT = os.getenv(
"DSH_SYSTEM_PROMPT", "DSH_SYSTEM_PROMPT",
f"You are {USER_NAME}'s helpful DeepSeek assistant on the family home lab. " f"You are {USER_NAME}'s helpful DeepSeek assistant on the family home lab. "
"Be clear, safe and concise. Never expose server file contents or system " "Be clear, safe and concise. Never expose server file contents or system "
"secrets. If asked something unsafe, decline politely.", "secrets. If asked something unsafe, decline politely.\n\n"
"You can speak aloud through the house speakers using the speak function "
"when the user asks to announce/say something over them (e.g. 'announce "
"X', 'tell Finn that...', 'say dinner is ready', or requests a voice "
"profile like Donald Trump or HAL 9000). Pass the exact message as text "
"and the requested voice when given. Only fall back to writing the message "
"in chat if the tool is unavailable.",
) )
# --- vision (image ingest) -> OpenRouter gpt-5 (works; OmniRoute auto/best-vision # --- vision (image ingest) -> OpenRouter gpt-5 (works; OmniRoute auto/best-vision
@@ -52,6 +58,9 @@ VISION_MODEL = os.getenv("DSH_VISION_MODEL", "openai/gpt-5")
# --- existing speech->text (voice_whisper on .13:5000, faster-whisper). Do NOT edit that service --- # --- existing speech->text (voice_whisper on .13:5000, faster-whisper). Do NOT edit that service ---
WHISPER_URL = os.getenv("DSH_WHISPER_URL", "http://192.168.20.13:5000/transcribe") WHISPER_URL = os.getenv("DSH_WHISPER_URL", "http://192.168.20.13:5000/transcribe")
# --- Jervis voice agent (.13:8501) — local agent that acts + speaks via Snapcast ---
VOICE_AGENT_URL = os.getenv("DSH_VOICE_AGENT_URL", "http://192.168.20.13:8501/voice")
VISION_KEY = os.getenv("DSH_VISION_KEY", "") VISION_KEY = os.getenv("DSH_VISION_KEY", "")
IMAGE_MODEL = os.getenv("DSH_IMAGE_MODEL", "openai/gpt-image-1") IMAGE_MODEL = os.getenv("DSH_IMAGE_MODEL", "openai/gpt-image-1")
VIDEO_MODEL = os.getenv("DSH_VIDEO_MODEL", "z-ai/glm-5v-turbo") VIDEO_MODEL = os.getenv("DSH_VIDEO_MODEL", "z-ai/glm-5v-turbo")
@@ -138,7 +147,63 @@ _SUM_HEAD = (
) )
def _strip_html(s: str) -> str: _SPEAK_TOOLS = [{"type": "function", "function": {
"name": "speak",
"description": "Speak text aloud through the house speakers via the Jervis voice agent (Snapcast). Use when the user wants an announcement, wants someone told something out loud, or asks for a voice profile.",
"parameters": {"type": "object", "properties": {
"text": {"type": "string", "description": "The exact text to say"},
"voice": {"type": "string", "description": "Optional voice profile name e.g. 'Donald Trump', 'HAL 9000', 'Picard'. Omit for the default voice."}
}, "required": ["text"]}
}}]
def _looks_speakish(msg: str) -> bool:
m = (msg or "").lower()
return any(h in m for h in (
"announce", "speak", "speaker", "shout", "out loud",
"say on ", "say over ", "say through ", "over the speakers",
"on the speakers", "through the speakers",
"tell finn", "tell jo", "tell harry", "tell sam", "tell the family",
))
async def _tool_speak(hist: list, model: str, headers: dict) -> str | None:
"""Tool-decide pass: offer the model a 'speak' function. Returns a display
string if an announcement was made, else None (no speak requested)."""
payload = {"model": model, "stream": False, "temperature": 0.4,
"tools": _SPEAK_TOOLS, "tool_choice": "auto",
"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + hist}
try:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.post(f"{LLM_BASE.rstrip('/')}/chat/completions",
json=payload, headers=headers)
r.raise_for_status()
msg = r.json()["choices"][0]["message"]
except Exception:
return None
for c in msg.get("tool_calls") or []:
fn = c.get("function") or {}
if fn.get("name") != "speak":
continue
try:
args = json.loads(fn.get("arguments") or "{}")
except Exception:
args = {}
text = (args.get("text") or "").strip()[:400]
if not text:
continue
voice = (args.get("voice") or "").strip()
command = (f"Speak in the {voice} voice: {text}"
if voice and voice.lower() not in ("default",) else text)
try:
async with httpx.AsyncClient(timeout=90) as client:
rr = await client.post(VOICE_AGENT_URL, json={"text": command})
ok = rr.status_code == 200
except Exception as exc:
return f"⚠️ Couldn't reach Jervis to speak: {exc}"
return f"🔊 {text}" if ok else "⚠️ Jervis couldn't speak it just now."
return None
s = re.sub(r"(?is)<(script|style|head|noscript|svg)[^>]*>.*?</\1>", " ", s) s = re.sub(r"(?is)<(script|style|head|noscript|svg)[^>]*>.*?</\1>", " ", s)
s = re.sub(r"(?s)<[^>]+>", " ", s) s = re.sub(r"(?s)<[^>]+>", " ", s)
return _html.unescape(re.sub(r"\s+", " ", s)).strip() return _html.unescape(re.sub(r"\s+", " ", s)).strip()
@@ -301,6 +366,19 @@ async def chat(message: Annotated[str, Form()],
active_model = sel or LLM_MODEL active_model = sel or LLM_MODEL
if active_model not in MODEL_CHOICES: if active_model not in MODEL_CHOICES:
active_model = LLM_MODEL active_model = LLM_MODEL
headers = {"Authorization": f"Bearer {key}"} if key else {}
if base == LLM_BASE:
# OmniRoute requires a session id for opencode-go models (else 400 "MissingSessionID")
headers["x-opencode-session"] = f"dsh-{os.getpid()}-{uuid4().hex[:8]}"
# Speak pass: only when the user sounds like they want an announcement.
# Non-streaming tool-decide, then stream a short confirmation.
if _looks_speakish(message):
spoke = await _tool_speak(hist, active_model, headers)
if spoke:
hist.append({"role": "assistant", "content": spoke})
save_session(sid, hist, session.get("model"))
yield f"data: {json.dumps({'c': spoke})}\n\n"
return
payload = { payload = {
"model": active_model, "model": active_model,
"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + hist, "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + hist,
@@ -573,6 +651,33 @@ async def generate_image(prompt: Annotated[str, Form()]) -> dict:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
@app.post("/api/tool/speak")
async def speak_text(text: Annotated[str, Form()], voice: Annotated[str, Form()] = "") -> dict:
"""Speak text through the house speakers (Jervis voice agent on .13:8501).
Acts and speaks via Snapcast. Optional 'voice' names a profile e.g.
'Donald Trump', 'HAL 9000', 'Picard'."""
text = (text or "").strip()[:500]
if not text:
return {"ok": False, "error": "No text to speak."}
command = text
if voice and voice.strip().lower() not in ("default", ""):
command = f"Speak in the {voice.strip()} voice: {text}"
try:
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post(VOICE_AGENT_URL, json={"text": command})
reply = ""
if r.status_code == 200:
try:
reply = (r.json().get("reply") or "")[:300]
except Exception: # noqa: BLE001
reply = ""
else:
return {"ok": False, "error": f"Jervis returned HTTP {r.status_code}"}
return {"ok": True, "spoken": command[:200], "reply": reply}
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"Speak failed: {exc}"}
MODEL_CHOICES = ["auto/best-chat", MODEL_CHOICES = ["auto/best-chat",
"deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash",