diff --git a/bolt_pet/doctor.py b/bolt_pet/doctor.py index 0545034..3c3a36e 100644 --- a/bolt_pet/doctor.py +++ b/bolt_pet/doctor.py @@ -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: + 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:]}") - 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") + 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: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index a9b207e..b4f38bc 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -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]