dsh: speak tool — announce over house speakers via Jervis (function-calling gate, zero latency for normal chat)
This commit is contained in:
109
dsh/app.py
109
dsh/app.py
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user