From c339d2c324b1da94a521efa6e27f3a2913822ac6 Mon Sep 17 00:00:00 2001 From: Serge Simono Date: Mon, 17 Aug 2026 05:31:05 +0200 Subject: [PATCH] fix(speak): honour the voice profile's language instead of forcing English Both speak surfaces built their GenerationRequest with a hardcoded "en" fallback and never consulted the resolved profile, so a profile created with language="fr" was still synthesised as English unless the caller passed language= explicitly. This hurts the MCP path most: an agent calling voicebox.speak has no way to know the bound profile's language, so it cannot pass the argument either. Every agent-triggered generation on a non-English profile came out with an English accent. The fallback chain is now explicit argument -> resolved profile's language -> "en", which matches how engine and personality already consult the resolved binding. The "en" backstop is kept so profiles with no language set behave exactly as before. Adds backend/tests/test_speak_language.py covering both surfaces: the fallback, explicit-argument precedence, and the unchanged "en" default. Co-Authored-By: Claude Opus 5 (1M context) --- backend/mcp_server/tools.py | 2 +- backend/routes/speak.py | 2 +- backend/tests/test_speak_language.py | 163 +++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_speak_language.py diff --git a/backend/mcp_server/tools.py b/backend/mcp_server/tools.py index 093e57ae..61003f11 100644 --- a/backend/mcp_server/tools.py +++ b/backend/mcp_server/tools.py @@ -104,7 +104,7 @@ def register_tools(mcp: FastMCP) -> None: profile_name=vp.name, text=text, engine=resolved_engine, - language=language, + language=language or vp.language, personality=use_persona, model_size=model_size, db=db, diff --git a/backend/routes/speak.py b/backend/routes/speak.py index 0c81846c..92efc75d 100644 --- a/backend/routes/speak.py +++ b/backend/routes/speak.py @@ -75,7 +75,7 @@ async def speak( models.GenerationRequest( profile_id=profile.id, text=data.text, - language=data.language or "en", + language=data.language or profile.language or "en", engine=engine, personality=bool(personality_flag), ), diff --git a/backend/tests/test_speak_language.py b/backend/tests/test_speak_language.py new file mode 100644 index 00000000..7a8f1e5e --- /dev/null +++ b/backend/tests/test_speak_language.py @@ -0,0 +1,163 @@ +"""Tests for voice-profile language fallback on the two speak surfaces. + +Both speak paths built their ``GenerationRequest`` with a hardcoded ``"en"`` +fallback and never consulted the resolved profile, so a profile created with +``language="fr"`` was still synthesised as English unless every caller passed +``language=`` explicitly. Agents going through MCP had no way to know the +profile's language, so they couldn't pass it either. + +These tests pin the fix: the fallback chain is now explicit argument → +resolved profile's language → ``"en"``, matching how ``engine`` and +``personality`` already consult the resolved binding. +""" + +import pytest + +import backend.routes.generations as generations +import backend.routes.speak as speak_route +from backend import models +from backend.mcp_server import tools + + +class _FakeGeneration: + """Minimal stand-in for GenerationResponse consumed by the speak paths.""" + + id = "gen-test" + status = "generating" + + def model_dump(self, mode="json"): + return {"id": self.id, "status": self.status} + + +class _FakeProfile: + def __init__(self, language): + self.id = "p1" + self.name = "Siwis" + self.language = language + self.personality = None + + +class _FakeQuery: + def filter(self, *args, **kwargs): + return self + + def first(self): + # No per-client binding — engine/personality fall through to their + # own defaults, leaving language as the only variable under test. + return None + + +class _FakeDB: + def query(self, *args, **kwargs): + return _FakeQuery() + + def close(self): + pass + + +class _FakeRequest: + """Stands in for starlette's Request — only headers are read.""" + + def __init__(self, client_id=None): + self.headers = {"X-Voicebox-Client-Id": client_id} if client_id else {} + + +@pytest.fixture +def captured_request(monkeypatch): + """Capture the GenerationRequest instead of running a real generation. + + Both speak paths import ``generate_speech`` lazily from + ``routes.generations``, so patching the attribute on that module + intercepts the call on either surface. + """ + captured = {} + + async def fake_generate_speech(req, db): + captured["req"] = req + return _FakeGeneration() + + monkeypatch.setattr(generations, "generate_speech", fake_generate_speech) + monkeypatch.setattr(speak_route.mcp_events, "publish", lambda *a, **k: None) + monkeypatch.setattr(tools.mcp_events, "publish", lambda *a, **k: None) + return captured + + +# ─── REST: POST /speak ──────────────────────────────────────────────────── + + +async def _call_rest(monkeypatch, profile_language, requested_language=None): + monkeypatch.setattr( + speak_route, + "resolve_profile", + lambda profile, client_id, db: _FakeProfile(profile_language), + ) + await speak_route.speak( + models.SpeakRequest(text="Bonjour", language=requested_language), + _FakeRequest(client_id="claude-code"), + _FakeDB(), + ) + + +async def test_rest_speak_falls_back_to_profile_language( + captured_request, monkeypatch +): + await _call_rest(monkeypatch, profile_language="fr") + assert captured_request["req"].language == "fr" + + +async def test_rest_speak_explicit_language_wins(captured_request, monkeypatch): + # An explicit argument still overrides the profile — a French profile can + # be asked to read an English string. + await _call_rest(monkeypatch, profile_language="fr", requested_language="en") + assert captured_request["req"].language == "en" + + +async def test_rest_speak_defaults_to_en_without_profile_language( + captured_request, monkeypatch +): + # Profiles predating the language column resolve to None; the "en" + # backstop keeps their behaviour unchanged. + await _call_rest(monkeypatch, profile_language=None) + assert captured_request["req"].language == "en" + + +# ─── MCP: voicebox.speak ────────────────────────────────────────────────── + + +async def _call_mcp(monkeypatch, profile_language, requested_language=None): + from mcp.server.fastmcp import FastMCP + + monkeypatch.setattr( + tools, + "resolve_profile", + lambda profile, client_id, db: _FakeProfile(profile_language), + ) + monkeypatch.setattr(tools, "get_db", lambda: iter([_FakeDB()])) + + mcp = FastMCP("test") + tools.register_tools(mcp) + args = {"text": "Bonjour"} + if requested_language is not None: + args["language"] = requested_language + await mcp.call_tool("voicebox.speak", args) + + +async def test_mcp_speak_falls_back_to_profile_language( + captured_request, monkeypatch +): + # The agent-facing path matters most: an MCP client can't know the + # profile's language, so omitting it must not silently mean English. + await _call_mcp(monkeypatch, profile_language="fr") + assert captured_request["req"].language == "fr" + + +async def test_mcp_speak_explicit_language_wins(captured_request, monkeypatch): + await _call_mcp(monkeypatch, profile_language="fr", requested_language="en") + assert captured_request["req"].language == "en" + + +async def test_mcp_speak_defaults_to_en_without_profile_language( + captured_request, monkeypatch +): + await _call_mcp(monkeypatch, profile_language=None) + assert captured_request["req"].language == "en"