From a7e02f38aa41c56f35030610aebc4129cbe907e5 Mon Sep 17 00:00:00 2001 From: kennyromanov Date: Fri, 30 Oct 2026 13:53:29 +0500 Subject: [PATCH 1/3] feat: The VAD options have been added to the interface and the greeting behavior has been changed --- README.md | 22 +++-- docs/README.md | 22 +++-- setup.py | 2 +- tests/calls/fakes.py | 7 +- tests/calls/test_call_cleanup.py | 4 +- tests/calls/test_call_service.py | 20 +++-- tests/calls/test_call_state_machine.py | 12 +++ tests/calls/test_realtime_voice.py | 91 ++++++++++++++++++-- whatsapp_chatbot_python/calls/__init__.py | 3 +- whatsapp_chatbot_python/calls/call_runner.py | 3 +- whatsapp_chatbot_python/calls/models.py | 41 ++++++++- whatsapp_chatbot_python/calls/openai.py | 47 ++++++++-- 12 files changed, 231 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index ebae40c..82a5fa9 100644 --- a/README.md +++ b/README.md @@ -540,10 +540,13 @@ credentials: ```python import asyncio -import logging from whatsapp_chatbot_python.calls import ( - CallEvent, CallSession, CallStateMachine, + CallEvent, + CallOptions, + CallSession, + CallStateMachine, + VadOptions, ) from whatsapp_chatbot_python.calls.service import WhatsAppCallService @@ -560,10 +563,17 @@ service = WhatsAppCallService( openai_api_key=openai_api_key, realtime_model=realtime_model, realtime_voice=realtime_voice, - ring_timeout_seconds=30, - talk_timeout_seconds=120, - shutdown_timeout_seconds=10, - logger=logging.getLogger("calls"), + options=CallOptions( + ring_seconds=30, + talk_seconds=120, + shutdown_seconds=10, + vad=VadOptions( + threshold=0.70, + prefix_padding_ms=400, + silence_duration_ms=800, + noise_reduction="near_field", + ), + ), ) async def call_once(): diff --git a/docs/README.md b/docs/README.md index 1b1a403..7d057d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -532,10 +532,13 @@ python -m pip install 'whatsapp-chatbot-python[voip]' ```python import asyncio -import logging from whatsapp_chatbot_python.calls import ( - CallEvent, CallSession, CallStateMachine, + CallEvent, + CallOptions, + CallSession, + CallStateMachine, + VadOptions, ) from whatsapp_chatbot_python.calls.service import WhatsAppCallService @@ -552,10 +555,17 @@ service = WhatsAppCallService( openai_api_key=openai_api_key, realtime_model=realtime_model, realtime_voice=realtime_voice, - ring_timeout_seconds=30, - talk_timeout_seconds=120, - shutdown_timeout_seconds=10, - logger=logging.getLogger("calls"), + options=CallOptions( + ring_seconds=30, + talk_seconds=120, + shutdown_seconds=10, + vad=VadOptions( + threshold=0.70, + prefix_padding_ms=400, + silence_duration_ms=800, + noise_reduction="near_field", + ), + ), ) async def call_once(): diff --git a/setup.py b/setup.py index f2d5d39..81a9538 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="whatsapp-chatbot-python", - version="1.1.1", + version="1.1.2", description=( "This library helps you easily create" " a Python chatbot with WhatsApp API." diff --git a/tests/calls/fakes.py b/tests/calls/fakes.py index 39d0a86..7d53ccc 100644 --- a/tests/calls/fakes.py +++ b/tests/calls/fakes.py @@ -126,8 +126,9 @@ async def hangUpAsync(self): class FakeVoice: - def __init__(self, scenario, *args): + def __init__(self, scenario, *args, vad=None): self.scenario = scenario + self.vad = vad self.greetings = [] self.closed = False self.started = False @@ -147,9 +148,9 @@ async def start(self): raise RuntimeError("voice unavailable") self.started = True - async def greet_once(self, text): + async def greet_once(self, prompt): if not self.greetings: - self.greetings.append(text) + self.greetings.append(prompt) self.scenario.actions.append("greet") def read_frame(self): diff --git a/tests/calls/test_call_cleanup.py b/tests/calls/test_call_cleanup.py index 6a4e826..d8192c0 100644 --- a/tests/calls/test_call_cleanup.py +++ b/tests/calls/test_call_cleanup.py @@ -26,8 +26,8 @@ def api_factory(*args, **kwargs): self.apis.append(api) return api - def voice_factory(*args): - voice = FakeVoice(self.scenario, *args) + def voice_factory(*args, **kwargs): + voice = FakeVoice(self.scenario, *args, **kwargs) self.voices.append(voice) return voice diff --git a/tests/calls/test_call_service.py b/tests/calls/test_call_service.py index 9d7588c..2901e97 100644 --- a/tests/calls/test_call_service.py +++ b/tests/calls/test_call_service.py @@ -8,7 +8,7 @@ from whatsapp_chatbot_python.calls.models import ( CallEndReason, CallEvent, CallOptions, CallSession, CallState, ProviderError, - RecordingOptions, + RecordingOptions, VadOptions, ) from whatsapp_chatbot_python.calls.service import WhatsAppCallService from whatsapp_chatbot_python.calls.state_machine import CallStateMachine @@ -28,8 +28,8 @@ def create_api(*args, **kwargs): self.apis.append(api) return api - def create_voice(*args): - voice = FakeVoice(self.scenario, *args) + def create_voice(*args, **kwargs): + voice = FakeVoice(self.scenario, *args, **kwargs) self.voices.append(voice) return voice @@ -41,7 +41,7 @@ def create_voice(*args): patcher.start() self.addCleanup(patcher.stop) - def service(self, *, ring=0.2, talk=0.2): + def service(self, *, ring=0.2, talk=0.2, vad=None): return WhatsAppCallService( api_url="https://example.invalid", id_instance="1", api_token_instance="token", openai_api_key="explicit-key", @@ -50,6 +50,7 @@ def service(self, *, ring=0.2, talk=0.2): ring_seconds=ring, talk_seconds=talk, setup_seconds=0.1, command_seconds=0.1, shutdown_seconds=0.1, recording=RecordingOptions(enabled=False), + vad=vad or VadOptions(), ), ) @@ -59,10 +60,11 @@ def outgoing(self): self.fsm.apply(session, CallEvent.DEQUEUED) return session - def incoming(self, *, call_id="call-1", deadline=None): + def incoming(self, *, call_id="call-1", deadline=None, prompt=None): session = CallSession.incoming( sender_id="sender@c.us", call_id=call_id, language="ru", incoming_deadline=deadline if deadline is not None else monotonic() + 1, + prompt=prompt, ) self.fsm.apply(session, CallEvent.ENQUEUED) self.fsm.apply(session, CallEvent.DEQUEUED) @@ -94,13 +96,19 @@ async def test_outgoing_answer_greets_once_and_preserves_remote_reason(self): async def test_incoming_accepts_matching_offer_and_greets_after_answer(self): self.scenario.state = "inc-call" - session = self.incoming() + session = self.incoming(prompt="Introduce yourself briefly.") await self.execute(self.service(), session) self.assertEqual(session.state, CallState.REMOTE_ENDED) self.assertTrue(session.incoming_accept_completed) self.assertLess(self.scenario.actions.index("accept"), self.scenario.actions.index("greet")) + self.assertEqual(self.voices[0].greetings, ["Introduce yourself briefly."]) self.assertTrue(self.apis[0].session_closed) + async def test_service_passes_vad_options_to_voice(self): + vad = VadOptions(threshold=0.85, noise_reduction="far_field") + await self.execute(self.service(vad=vad), self.outgoing()) + self.assertIs(self.voices[0].vad, vad) + async def test_incoming_call_id_takes_priority_over_pn_or_lid_address(self): self.scenario.state = "inc-call" self.scenario.wid = "sender@lid" diff --git a/tests/calls/test_call_state_machine.py b/tests/calls/test_call_state_machine.py index 048b0f8..664273f 100644 --- a/tests/calls/test_call_state_machine.py +++ b/tests/calls/test_call_state_machine.py @@ -17,6 +17,18 @@ def setUp(self) -> None: self.fsm = CallStateMachine() self.session = CallSession("sender@c.us", "sender@c.us", "ru") + def test_incoming_call_keeps_its_prompt(self) -> None: + session = CallSession.incoming( + sender_id="sender@c.us", call_id="call-1", language="adaptive", + incoming_deadline=monotonic() + 1, + prompt="Introduce yourself briefly.", + ) + self.assertEqual(session.prompt, "Introduce yourself briefly.") + + def test_empty_prompt_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "Call prompt must not be empty"): + CallSession("sender@c.us", "sender@c.us", "adaptive", prompt=" ") + def apply(self, event: CallEvent) -> CallState: self.fsm.apply(self.session, event) return self.session.state diff --git a/tests/calls/test_realtime_voice.py b/tests/calls/test_realtime_voice.py index b0cb01e..5884ac5 100644 --- a/tests/calls/test_realtime_voice.py +++ b/tests/calls/test_realtime_voice.py @@ -7,7 +7,7 @@ from unittest.mock import patch from whatsapp_chatbot_python.calls.models import ( - AudioLoss, MediaFailed, PCM, PlaybackOptions, ProviderConfig, + AudioLoss, CallOptions, MediaFailed, PCM, PlaybackOptions, ProviderConfig, VadOptions, ) from whatsapp_chatbot_python.calls.openai import RealtimeConversation from whatsapp_chatbot_python.calls.playback_state_machine import PlaybackController @@ -146,19 +146,55 @@ async def audio(self, payload=FRAME, response="response-1", item="item-1"): delta=base64.b64encode(payload).decode("ascii"), )) - async def test_session_uses_explicit_key_and_greets_only_once(self): + async def test_explicit_prompt_is_sent_unchanged_only_once(self): self.assertEqual(self.client.api_key, "explicit-key") settings = self.client.connection.actions[0][1]["session"] self.assertEqual(settings["audio"]["output"]["voice"], "marin") self.assertEqual(settings["audio"]["input"]["format"]["rate"], PCM.rate) - await self.voice.greet_once("Привет") - await self.voice.greet_once("Ещё раз") + self.assertEqual(settings["audio"]["input"]["noise_reduction"], + {"type": "near_field"}) + self.assertEqual(settings["audio"]["input"]["turn_detection"], { + "type": "server_vad", + "threshold": 0.70, + "prefix_padding_ms": 400, + "silence_duration_ms": 800, + "create_response": True, + "interrupt_response": True, + }) + await self.voice.greet_once("Introduce yourself and ask how you can help.") + await self.voice.greet_once("This prompt must be ignored.") self.assertEqual([action for action, _ in self.client.connection.actions].count("create"), 1) self.assertEqual( self.client.connection.actions[-1][1]["response"]["instructions"], - 'Greet the caller: "Привет" in Russian.', + "Introduce yourself and ask how you can help.", ) + async def test_custom_vad_settings_reach_realtime(self): + vad = VadOptions( + threshold=0.85, prefix_padding_ms=250, + silence_duration_ms=1200, noise_reduction="far_field", + ) + config = ProviderConfig("https://example.invalid", "1", "token", "key", "model", "marin") + async with RealtimeConversation( + config, PlaybackOptions(), self._record_event, "ru", 0.1, vad=vad, + ) as voice: + await voice.start() + audio_input = voice._client.connection.actions[0][1]["session"]["audio"]["input"] + + self.assertEqual(audio_input["noise_reduction"], {"type": "far_field"}) + self.assertEqual(audio_input["turn_detection"], { + "type": "server_vad", + "threshold": 0.85, + "prefix_padding_ms": 250, + "silence_duration_ms": 1200, + "create_response": True, + "interrupt_response": True, + }) + + async def test_none_disables_noise_reduction(self): + self.voice._vad = VadOptions(noise_reduction=None) + self.assertIsNone(self.voice._session_settings()["audio"]["input"]["noise_reduction"]) + async def test_default_greeting_instruction_uses_language_name(self): await self.voice.greet_once(None) self.assertEqual( @@ -166,6 +202,23 @@ async def test_default_greeting_instruction_uses_language_name(self): "Greet the caller briefly in Russian.", ) + async def test_adaptive_language_uses_switching_fallbacks(self): + self.voice._language = "adaptive" + settings = self.voice._session_settings() + self.assertEqual( + settings["instructions"], + "Have a concise, helpful phone conversation. " + "If the user speaks another language, naturally switch to that language. " + "The conversation is not only in English.", + ) + + await self.voice.greet_once(None) + self.assertEqual( + self.client.connection.actions[-1][1]["response"]["instructions"], + "Greet the caller briefly. If the user speaks another language, " + "naturally switch to that language. The conversation is not only in English.", + ) + async def test_incoming_pcm_is_base64_encoded(self): await self.voice.write_audio(FRAME) self.assertEqual(self.client.connection.actions[-1], @@ -182,6 +235,16 @@ async def test_barge_in_truncates_to_audio_actually_heard(self): self.assertEqual(actions[-1][1], dict(item_id="item-1", content_index=0, audio_end_ms=PCM.frame_ms)) + async def test_provider_cancel_error_after_speech_started_is_ignored(self): + await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-1"))) + await self.dispatch(dict(type="input_audio_buffer.speech_started")) + cancel = next(data for name, data in self.client.connection.actions if name == "cancel") + await self.dispatch(dict(type="error", error=SimpleNamespace( + event_id=cancel["event_id"], code="response_cancel_failed", message="Already cancelled", + ))) + self.assertEqual(self.failures, []) + + async def test_replaced_track_drops_old_audio_and_new_response_plays(self): await self.dispatch(dict(type="response.created", response=SimpleNamespace(id="response-1"))) await self.audio(FRAME * 2) @@ -292,3 +355,21 @@ async def test_finished_cancelled_response_releases_tracking(self): await self.dispatch(dict(type="response.done", response=SimpleNamespace( id="response-1", status="cancelled"))) self.assertNotIn("response-1", self.voice._cancelled) + + +class VadOptionsTest(unittest.TestCase): + def test_invalid_vad_settings_are_rejected_before_call_start(self): + invalid_settings = ( + {"threshold": -0.1}, {"threshold": 1.1}, + {"threshold": float("nan")}, {"threshold": float("inf")}, + {"threshold": True}, {"threshold": "0.7"}, + {"prefix_padding_ms": -1}, {"prefix_padding_ms": True}, + {"prefix_padding_ms": 0.5}, {"silence_duration_ms": 0}, + {"silence_duration_ms": True}, {"noise_reduction": "unknown"}, + ) + for settings in invalid_settings: + with self.subTest(settings=settings), self.assertRaises(ValueError): + VadOptions(**settings) + + with self.assertRaisesRegex(ValueError, "VAD options must be VadOptions"): + CallOptions(vad=None) diff --git a/whatsapp_chatbot_python/calls/__init__.py b/whatsapp_chatbot_python/calls/__init__.py index 947ef1e..df12a37 100644 --- a/whatsapp_chatbot_python/calls/__init__.py +++ b/whatsapp_chatbot_python/calls/__init__.py @@ -24,6 +24,7 @@ RecordingOptions, StateTransition, TransitionCallback, + VadOptions, ACTIVE_STATES, TERMINAL_STATES, WAITING_STATES, @@ -37,5 +38,5 @@ "CallExecutionResult", "CallExecutor", "CallOptions", "CallSession", "CallServiceUnavailable", "CallState", "CallStateMachine", "IncomingCallExecutor", "InputAudioOptions", "InputOverflowPolicy", "PlaybackOptions", "PlaybackOverflowPolicy", "ProviderError", "RecordingOptions", "StateTransition", "TransitionCallback", - "WhatsAppCallService", + "VadOptions", "WhatsAppCallService", ] diff --git a/whatsapp_chatbot_python/calls/call_runner.py b/whatsapp_chatbot_python/calls/call_runner.py index c1ad5ef..59e9071 100644 --- a/whatsapp_chatbot_python/calls/call_runner.py +++ b/whatsapp_chatbot_python/calls/call_runner.py @@ -203,6 +203,7 @@ def __init__( emit, session.language, options.shutdown_seconds, + vad=options.vad, ) self.audio = AudioBridge( @@ -730,7 +731,7 @@ def _activate_conversation(self, at: float) -> None: self._start( Command.GREET, - lambda: self._resources.voice.greet_once(self.session.greeting_text), + lambda: self._resources.voice.greet_once(self.session.prompt), ) def _caller_left(self, call_id: str) -> None: diff --git a/whatsapp_chatbot_python/calls/models.py b/whatsapp_chatbot_python/calls/models.py index bd59cff..ad4cb9e 100644 --- a/whatsapp_chatbot_python/calls/models.py +++ b/whatsapp_chatbot_python/calls/models.py @@ -132,7 +132,7 @@ class CallSession: state: CallState = CallState.TRIGGERED call_id: str | None = None incoming_deadline: float | None = None - greeting_text: str | None = None + prompt: str | None = None end_reason: CallEndReason | None = None remote_reason: str | None = None error_code: str | None = None @@ -145,6 +145,10 @@ class CallSession: bridge_ready: bool = False incoming_accept_completed: bool = False + def __post_init__(self) -> None: + if self.prompt is not None and not self.prompt.strip(): + raise ValueError("Call prompt must not be empty") + @property def finished(self) -> bool: return self.state in TERMINAL_STATES @@ -156,7 +160,7 @@ def incoming( sender_id: str, call_id: str, language: str, - incoming_deadline: float, greeting_text: str | None = None, + incoming_deadline: float, prompt: str | None = None, chat_id: str | None = None, ) -> CallSession: if not sender_id or not call_id: @@ -172,7 +176,7 @@ def incoming( direction=CallDirection.INCOMING, call_id=call_id, incoming_deadline=incoming_deadline, - greeting_text=greeting_text, + prompt=prompt, ) @@ -361,6 +365,33 @@ def __post_init__(self) -> None: object.__setattr__(self, "overflow_policy", policy) +@dataclass(frozen=True) +class VadOptions: + """Server VAD settings for caller audio in OpenAI Realtime.""" + threshold: float = 0.70 + prefix_padding_ms: int = 400 + silence_duration_ms: int = 800 + noise_reduction: Literal["near_field", "far_field"] | None = "near_field" + + def __post_init__(self) -> None: + if ( + isinstance(self.threshold, bool) + or not isinstance(self.threshold, (int, float)) + or not isfinite(self.threshold) + or not 0 <= self.threshold <= 1 + ): + raise ValueError("VAD threshold must be a finite number between 0 and 1") + + if type(self.prefix_padding_ms) is not int or self.prefix_padding_ms < 0: + raise ValueError("VAD prefix padding must be non-negative milliseconds") + + if type(self.silence_duration_ms) is not int or self.silence_duration_ms <= 0: + raise ValueError("VAD silence duration must be positive milliseconds") + + if self.noise_reduction not in ("near_field", "far_field", None): + raise ValueError("Unknown VAD noise reduction setting") + + @dataclass(frozen=True) class AudioLoss: """Samples lost since the previous notice; the call can keep running.""" @@ -386,6 +417,7 @@ class CallOptions: input_audio: InputAudioOptions = field(default_factory=InputAudioOptions) # Return promptly; schedule slow application work in another task. on_audio_loss: Callable[[CallSession, AudioLoss], None] | None = None + vad: VadOptions = field(default_factory=VadOptions) def __post_init__(self) -> None: positive_values( @@ -399,6 +431,9 @@ def __post_init__(self) -> None: if self.on_audio_loss is not None and not callable(self.on_audio_loss): raise ValueError("Audio loss callback must be callable") + if not isinstance(self.vad, VadOptions): + raise ValueError("VAD options must be VadOptions") + @dataclass(frozen=True) class ProviderConfig: diff --git a/whatsapp_chatbot_python/calls/openai.py b/whatsapp_chatbot_python/calls/openai.py index e1f3017..09f2d76 100644 --- a/whatsapp_chatbot_python/calls/openai.py +++ b/whatsapp_chatbot_python/calls/openai.py @@ -15,6 +15,7 @@ PlaybackOptions, ProviderConfig, ProviderError, + VadOptions, PCM, ) @@ -47,12 +48,15 @@ def __init__( emit: EventSink, language: str, shutdown_seconds: float, + *, + vad: VadOptions | None = None, ) -> None: self._config = config self._options = playback self._emit = emit self._language = language self._shutdown_seconds = shutdown_seconds + self._vad = vad if vad is not None else VadOptions() self._stack = AsyncExitStack() self._connection = None self._client = None @@ -115,9 +119,7 @@ async def _close_connection(self) -> None: await asyncio.wait_for(self._connection.close(), self._shutdown_seconds) def _session_settings(self) -> dict: - instructions = self._config.instructions or ( - f"Have a concise, helpful phone conversation in {self._language}." - ) + instructions = self._config.instructions or self._default_conversation_instructions() return { "type": "realtime", @@ -126,7 +128,18 @@ def _session_settings(self) -> dict: "audio": { "input": { "format": {"type": "audio/pcm", "rate": PCM.rate}, - "turn_detection": {"type": "semantic_vad"}, + "noise_reduction": ( + {"type": self._vad.noise_reduction} + if self._vad.noise_reduction is not None else None + ), + "turn_detection": { + "type": "server_vad", + "threshold": self._vad.threshold, + "prefix_padding_ms": self._vad.prefix_padding_ms, + "silence_duration_ms": self._vad.silence_duration_ms, + "create_response": True, + "interrupt_response": True, + }, }, "output": { "format": {"type": "audio/pcm", "rate": PCM.rate}, @@ -135,6 +148,13 @@ def _session_settings(self) -> dict: }, } + def _default_conversation_instructions(self) -> str: + if self._language == "adaptive": + # Workaround: English-only instructions can keep the model speaking English. + return "Greet the caller briefly. If the user speaks another language, naturally switch to that language. The conversation is not only in English." + + return f"Greet the caller briefly in {self._language}." + async def _await_configuration(self) -> None: async for event in self._connection: match event.type: @@ -200,23 +220,32 @@ async def write_audio(self, data: bytes) -> None: await self._connection.input_audio_buffer.append( audio=base64.b64encode(data).decode("ascii")) - async def greet_once(self, greeting_text: str | None) -> None: + async def greet_once(self, prompt: str | None) -> None: if self._greeted: return self._greeted = True - language_name = _LANGUAGE_NAMES.get(self._language, "English") greeting_instruction = ( - f'Greet the caller: "{greeting_text}" in {language_name}.' - if greeting_text else - f"Greet the caller briefly in {language_name}." + prompt if prompt is not None else self._default_greeting_instruction() ) await self._connection.response.create(response={ "instructions": greeting_instruction, }) + def _default_greeting_instruction(self) -> str: + if self._language == "adaptive": + # Workaround for the model staying in English after an English greeting. + return ( + "Greet the caller briefly. If the user speaks another language, " + "naturally switch to that language. The conversation is not only in English." + ) + + language_name = _LANGUAGE_NAMES.get(self._language, "English") + + return f"Greet the caller briefly in {language_name}." + async def _read(self) -> None: async for event in self._connection: await self._dispatch(event) From ba2356ee05c70d5bf863e1876a52f34e40758c22 Mon Sep 17 00:00:00 2001 From: kennyromanov Date: Fri, 30 Oct 2026 15:11:18 +0500 Subject: [PATCH 2/3] fix: The linting and the tests have been fixed --- tests/calls/test_realtime_voice.py | 22 ++++++++++------------ whatsapp_chatbot_python/calls/openai.py | 10 +++++++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/calls/test_realtime_voice.py b/tests/calls/test_realtime_voice.py index 5884ac5..ac6ee7c 100644 --- a/tests/calls/test_realtime_voice.py +++ b/tests/calls/test_realtime_voice.py @@ -199,25 +199,23 @@ async def test_default_greeting_instruction_uses_language_name(self): await self.voice.greet_once(None) self.assertEqual( self.client.connection.actions[-1][1]["response"]["instructions"], - "Greet the caller briefly in Russian.", + "Greet the user briefly in Russian.", ) async def test_adaptive_language_uses_switching_fallbacks(self): self.voice._language = "adaptive" settings = self.voice._session_settings() - self.assertEqual( - settings["instructions"], - "Have a concise, helpful phone conversation. " - "If the user speaks another language, naturally switch to that language. " - "The conversation is not only in English.", - ) + required_phrases = ("another language", "switch to", "in English") + session_instructions = settings["instructions"].lower() + for phrase in required_phrases: + with self.subTest(target="session", phrase=phrase): + self.assertIn(phrase.lower(), session_instructions) await self.voice.greet_once(None) - self.assertEqual( - self.client.connection.actions[-1][1]["response"]["instructions"], - "Greet the caller briefly. If the user speaks another language, " - "naturally switch to that language. The conversation is not only in English.", - ) + greeting_instructions = self.client.connection.actions[-1][1]["response"]["instructions"].lower() + for phrase in required_phrases: + with self.subTest(target="greeting", phrase=phrase): + self.assertIn(phrase.lower(), greeting_instructions) async def test_incoming_pcm_is_base64_encoded(self): await self.voice.write_audio(FRAME) diff --git a/whatsapp_chatbot_python/calls/openai.py b/whatsapp_chatbot_python/calls/openai.py index 09f2d76..0a76055 100644 --- a/whatsapp_chatbot_python/calls/openai.py +++ b/whatsapp_chatbot_python/calls/openai.py @@ -151,7 +151,11 @@ def _session_settings(self) -> dict: def _default_conversation_instructions(self) -> str: if self._language == "adaptive": # Workaround: English-only instructions can keep the model speaking English. - return "Greet the caller briefly. If the user speaks another language, naturally switch to that language. The conversation is not only in English." + return ( + "Greet the user briefly. " + "If the user speaks another language, naturally switch to that language. " + "The conversation is not only in English." + ) return f"Greet the caller briefly in {self._language}." @@ -238,13 +242,13 @@ def _default_greeting_instruction(self) -> str: if self._language == "adaptive": # Workaround for the model staying in English after an English greeting. return ( - "Greet the caller briefly. If the user speaks another language, " + "Greet the user briefly. If the user speaks another language, " "naturally switch to that language. The conversation is not only in English." ) language_name = _LANGUAGE_NAMES.get(self._language, "English") - return f"Greet the caller briefly in {language_name}." + return f"Greet the user briefly in {language_name}." async def _read(self) -> None: async for event in self._connection: From 55bf1574db31bdc100da5e4865cc5215c93622a6 Mon Sep 17 00:00:00 2001 From: kennyromanov Date: Fri, 30 Oct 2026 16:12:20 +0500 Subject: [PATCH 3/3] fix: The issues with repeatable greetings has been fixed --- README.md | 2 +- docs/README.md | 2 +- setup.py | 2 +- tests/calls/fakes.py | 2 +- tests/calls/test_realtime_voice.py | 32 +++++++++++++++----- whatsapp_chatbot_python/calls/call_runner.py | 2 +- whatsapp_chatbot_python/calls/openai.py | 19 +++--------- 7 files changed, 34 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 82a5fa9..2388ba8 100644 --- a/README.md +++ b/README.md @@ -621,7 +621,7 @@ session = CallSession.incoming( call_id=call_id, language="en", incoming_deadline=monotonic() + remaining_seconds, - greeting_text="Hello! This is a WhatsApp demo call. What would you like to discuss?", + prompt="Greet the user: 'Hello! This is a WhatsApp demo call. What would you like to discuss?'", ) fsm = CallStateMachine() fsm.apply(session, CallEvent.ENQUEUED) diff --git a/docs/README.md b/docs/README.md index 7d057d2..2f9f996 100644 --- a/docs/README.md +++ b/docs/README.md @@ -613,7 +613,7 @@ session = CallSession.incoming( call_id=call_id, language="ru", incoming_deadline=monotonic() + remaining_seconds, - greeting_text="Здравствуйте! Это демонстрационный звонок WhatsApp. Что хотите обсудить?", + prompt="Поприветствуй пользователя: 'Здравствуйте! Это демонстрационный звонок WhatsApp. Что хотите обсудить?'", ) fsm = CallStateMachine() fsm.apply(session, CallEvent.ENQUEUED) diff --git a/setup.py b/setup.py index 81a9538..65afef5 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="whatsapp-chatbot-python", - version="1.1.2", + version="1.2.0", description=( "This library helps you easily create" " a Python chatbot with WhatsApp API." diff --git a/tests/calls/fakes.py b/tests/calls/fakes.py index 7d53ccc..efad785 100644 --- a/tests/calls/fakes.py +++ b/tests/calls/fakes.py @@ -148,7 +148,7 @@ async def start(self): raise RuntimeError("voice unavailable") self.started = True - async def greet_once(self, prompt): + async def prompt_once(self, prompt): if not self.greetings: self.greetings.append(prompt) self.scenario.actions.append("greet") diff --git a/tests/calls/test_realtime_voice.py b/tests/calls/test_realtime_voice.py index ac6ee7c..4045f4a 100644 --- a/tests/calls/test_realtime_voice.py +++ b/tests/calls/test_realtime_voice.py @@ -161,8 +161,8 @@ async def test_explicit_prompt_is_sent_unchanged_only_once(self): "create_response": True, "interrupt_response": True, }) - await self.voice.greet_once("Introduce yourself and ask how you can help.") - await self.voice.greet_once("This prompt must be ignored.") + await self.voice.prompt_once("Introduce yourself and ask how you can help.") + await self.voice.prompt_once("This prompt must be ignored.") self.assertEqual([action for action, _ in self.client.connection.actions].count("create"), 1) self.assertEqual( self.client.connection.actions[-1][1]["response"]["instructions"], @@ -196,22 +196,38 @@ async def test_none_disables_noise_reduction(self): self.assertIsNone(self.voice._session_settings()["audio"]["input"]["noise_reduction"]) async def test_default_greeting_instruction_uses_language_name(self): - await self.voice.greet_once(None) + await self.voice.prompt_once(None) self.assertEqual( self.client.connection.actions[-1][1]["response"]["instructions"], "Greet the user briefly in Russian.", ) + async def test_default_greeting_is_not_used_as_session_instruction(self): + self.assertNotIn("instructions", self.voice._session_settings()) + + async def test_explicit_conversation_instructions_remain_session_wide(self): + config = ProviderConfig( + "https://example.invalid", "1", "token", "key", "model", "marin", + "You are a helpful Russian-speaking phone assistant.", + ) + async with RealtimeConversation( + config, PlaybackOptions(), self._record_event, "ru", 0.1, + ) as voice: + await voice.start() + + settings = self.client.connection.actions[0][1]["session"] + self.assertEqual( + settings["instructions"], + "You are a helpful Russian-speaking phone assistant.", + ) + async def test_adaptive_language_uses_switching_fallbacks(self): self.voice._language = "adaptive" settings = self.voice._session_settings() required_phrases = ("another language", "switch to", "in English") - session_instructions = settings["instructions"].lower() - for phrase in required_phrases: - with self.subTest(target="session", phrase=phrase): - self.assertIn(phrase.lower(), session_instructions) + self.assertNotIn("instructions", settings) - await self.voice.greet_once(None) + await self.voice.prompt_once(None) greeting_instructions = self.client.connection.actions[-1][1]["response"]["instructions"].lower() for phrase in required_phrases: with self.subTest(target="greeting", phrase=phrase): diff --git a/whatsapp_chatbot_python/calls/call_runner.py b/whatsapp_chatbot_python/calls/call_runner.py index 59e9071..3cf1f9a 100644 --- a/whatsapp_chatbot_python/calls/call_runner.py +++ b/whatsapp_chatbot_python/calls/call_runner.py @@ -731,7 +731,7 @@ def _activate_conversation(self, at: float) -> None: self._start( Command.GREET, - lambda: self._resources.voice.greet_once(self.session.prompt), + lambda: self._resources.voice.prompt_once(self.session.prompt), ) def _caller_left(self, call_id: str) -> None: diff --git a/whatsapp_chatbot_python/calls/openai.py b/whatsapp_chatbot_python/calls/openai.py index 0a76055..42a7a32 100644 --- a/whatsapp_chatbot_python/calls/openai.py +++ b/whatsapp_chatbot_python/calls/openai.py @@ -119,11 +119,8 @@ async def _close_connection(self) -> None: await asyncio.wait_for(self._connection.close(), self._shutdown_seconds) def _session_settings(self) -> dict: - instructions = self._config.instructions or self._default_conversation_instructions() - - return { + settings = { "type": "realtime", - "instructions": instructions, "output_modalities": ["audio"], "audio": { "input": { @@ -148,16 +145,10 @@ def _session_settings(self) -> dict: }, } - def _default_conversation_instructions(self) -> str: - if self._language == "adaptive": - # Workaround: English-only instructions can keep the model speaking English. - return ( - "Greet the user briefly. " - "If the user speaks another language, naturally switch to that language. " - "The conversation is not only in English." - ) + if self._config.instructions is not None: + settings["instructions"] = self._config.instructions - return f"Greet the caller briefly in {self._language}." + return settings async def _await_configuration(self) -> None: async for event in self._connection: @@ -224,7 +215,7 @@ async def write_audio(self, data: bytes) -> None: await self._connection.input_audio_buffer.append( audio=base64.b64encode(data).decode("ascii")) - async def greet_once(self, prompt: str | None) -> None: + async def prompt_once(self, prompt: str | None) -> None: if self._greeted: return