Add a --deep probe to check_tts so a live TTS failure isn't invisible
Reported: the pet was speaking in the offline pyttsx3 voice instead of the server-synthesized one. speak()'s fallback chain is silent by design (a TtsError just logs and falls through to pyttsx3), and the shallow doctor check only ever verified config presence, not that a real /desk/tts call actually succeeds — so "misconfigured" and "configured but the server rejects it" looked identical from the outside. check_tts(deep=True) now posts a couple of words to /desk/tts for real and reports the exact HTTP status and server error message (a 404 means the voice id isn't real or isn't owned by this key - the likely shape of a Voicebox-clone ownership mismatch; 402 means the account is out of credits; 401 means DESK_API_KEY is wrong). Wired through the same CHECKS/run() deep-parameter dance check_streaming_endpoint already uses. 5 new tests, including one pinning that run()'s TypeError-fallback actually reaches check_tts's deep parameter rather than silently running it shallow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CBvympe6SuHdhf9q1VQqS
This commit is contained in:
+43
-5
@@ -152,18 +152,56 @@ def check_stt() -> Check:
|
||||
return Check("speech-to-text", OK, f"server relay, {mode}")
|
||||
|
||||
|
||||
def check_tts() -> Check:
|
||||
def check_tts(deep: bool = False) -> Check:
|
||||
"""TTS is the server's /desk/tts — no local ElevenLabs account needed for
|
||||
the normal reply voice, just a voice id for it to request."""
|
||||
if config.is_configured() and config.ELEVENLABS_VOICE_ID:
|
||||
return Check("text-to-speech", OK,
|
||||
f"server relay, voice …{config.ELEVENLABS_VOICE_ID[-6:]}")
|
||||
the normal reply voice, just a voice id for it to request.
|
||||
|
||||
Shallow only checks config presence, same as the other shallow checks —
|
||||
it can't see a live failure (bad voice id, an expired key, a Voicebox
|
||||
clone owned by someone else). --deep actually calls /desk/tts with a
|
||||
couple of words and reports the real HTTP status/error, which is the
|
||||
only way to tell "misconfigured" apart from "configured but broken"
|
||||
(e.g. speak() silently falling back to the offline voice)."""
|
||||
if not (config.is_configured() and config.ELEVENLABS_VOICE_ID):
|
||||
if _module("pyttsx3"):
|
||||
return Check("text-to-speech", WARN, "server/voice not configured — offline voice only",
|
||||
"set BOLT_SERVER_URL/DESK_API_KEY and ELEVENLABS_VOICE_ID for the real voice")
|
||||
return Check("text-to-speech", FAIL, "no server/voice config and no pyttsx3 fallback",
|
||||
"set BOLT_SERVER_URL/DESK_API_KEY/ELEVENLABS_VOICE_ID, "
|
||||
"or pip install pyttsx3 for an offline voice")
|
||||
if not deep:
|
||||
return Check("text-to-speech", OK,
|
||||
f"server relay, voice …{config.ELEVENLABS_VOICE_ID[-6:]} "
|
||||
"(not probed; --deep to actually synthesize)")
|
||||
try:
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/tts",
|
||||
headers={"X-Desk-Api-Key": config.API_KEY},
|
||||
json={"session_id": config.SESSION_ID, "text": "testing",
|
||||
"voice_id": config.ELEVENLABS_VOICE_ID},
|
||||
timeout=20,
|
||||
)
|
||||
except Exception as exc:
|
||||
return Check("text-to-speech", FAIL, f"probe failed: {exc}",
|
||||
"check BOLT_SERVER_URL is reachable from this machine")
|
||||
if response.status_code == 200 and response.content:
|
||||
return Check("text-to-speech", OK,
|
||||
f"server synthesized {len(response.content)} bytes "
|
||||
f"for voice …{config.ELEVENLABS_VOICE_ID[-6:]}")
|
||||
detail = ""
|
||||
try:
|
||||
detail = str(response.json().get("error") or "")
|
||||
except Exception:
|
||||
pass
|
||||
return Check(
|
||||
"text-to-speech", FAIL,
|
||||
f"server returned {response.status_code}{': ' + detail if detail else ''} "
|
||||
f"for voice …{config.ELEVENLABS_VOICE_ID[-6:]}",
|
||||
"a 404 means this voice id isn't yours (or doesn't exist); 402 means "
|
||||
"the account is out of credits; 401 means DESK_API_KEY is wrong",
|
||||
)
|
||||
|
||||
|
||||
def check_dialogue() -> Check:
|
||||
|
||||
@@ -9,6 +9,7 @@ pinning down.
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -112,3 +113,75 @@ def test_a_slow_silence_timeout_is_flagged(monkeypatch):
|
||||
def test_a_check_line_renders_the_fix_only_when_there_is_a_problem():
|
||||
assert "→" not in doctor.Check("x", OK, "all good", fix="unused").line()
|
||||
assert "→ do the thing" in doctor.Check("x", WARN, "hmm", fix="do the thing").line()
|
||||
|
||||
|
||||
# ── check_tts's --deep probe ────────────────────────────────────────────────
|
||||
# check_streaming_endpoint already established the pattern: shallow only
|
||||
# checks config (no network), --deep actually calls the server. TTS needs
|
||||
# its own deep probe specifically because "configured but broken" (a
|
||||
# rejected voice id, a dead credit balance) is otherwise indistinguishable
|
||||
# from "working" until speak() silently falls back to the offline voice —
|
||||
# which is exactly the bug report this was added to diagnose.
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tts_configured(monkeypatch):
|
||||
monkeypatch.setattr(config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(config, "API_KEY", "test-key")
|
||||
monkeypatch.setattr(config, "SESSION_ID", "pet-test")
|
||||
monkeypatch.setattr(config, "ELEVENLABS_VOICE_ID", "abc123voiceid")
|
||||
|
||||
|
||||
def test_tts_shallow_does_not_touch_the_network():
|
||||
with patch("requests.post") as post:
|
||||
check = doctor.check_tts(deep=False)
|
||||
post.assert_not_called()
|
||||
assert check.status == OK
|
||||
assert "not probed" in check.detail
|
||||
|
||||
|
||||
def test_tts_deep_reports_success():
|
||||
response = MagicMock(status_code=200, content=b"\x00\x01" * 100)
|
||||
with patch("requests.post", return_value=response) as post:
|
||||
check = doctor.check_tts(deep=True)
|
||||
post.assert_called_once()
|
||||
assert check.status == OK
|
||||
assert "200 bytes" in check.detail
|
||||
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/tts"
|
||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||
assert kwargs["json"]["voice_id"] == "abc123voiceid"
|
||||
|
||||
|
||||
def test_tts_deep_surfaces_the_servers_error_message():
|
||||
"""A 404 with 'voice not found' is exactly the shape of the reported bug:
|
||||
a vb: clone owned by someone else, or a stale/mistyped id."""
|
||||
response = MagicMock(status_code=404)
|
||||
response.json.return_value = {"error": "voice not found"}
|
||||
with patch("requests.post", return_value=response):
|
||||
check = doctor.check_tts(deep=True)
|
||||
assert check.status == FAIL
|
||||
assert "404" in check.detail
|
||||
assert "voice not found" in check.detail
|
||||
assert check.fix
|
||||
|
||||
|
||||
def test_tts_deep_handles_a_network_failure_without_raising():
|
||||
with patch("requests.post", side_effect=OSError("no route to host")):
|
||||
check = doctor.check_tts(deep=True)
|
||||
assert check.status == FAIL
|
||||
assert "no route to host" in check.detail
|
||||
|
||||
|
||||
def test_tts_run_wires_deep_through_automatically(monkeypatch):
|
||||
"""run()'s TypeError-fallback dance (doctor.py's CHECKS loop) must
|
||||
actually reach check_tts's deep parameter, not silently call it shallow."""
|
||||
seen = []
|
||||
|
||||
def fake_check_tts(deep=False):
|
||||
seen.append(deep)
|
||||
return doctor.Check("text-to-speech", OK, "stub")
|
||||
|
||||
monkeypatch.setattr(doctor, "CHECKS", (("tts", fake_check_tts),))
|
||||
doctor.run(deep=True)
|
||||
assert seen == [True]
|
||||
|
||||
Reference in New Issue
Block a user