feat(voice): add elevenlabs stt and transcript truth fixes

This commit is contained in:
Yera All
2026-04-18 18:25:07 +05:00
parent ac2269b6a4
commit 34b807e460
9 changed files with 807 additions and 18 deletions
+32 -2
View File
@@ -3104,7 +3104,8 @@ def test_custom_voice_name_texts_are_used_in_start_and_followup():
metadata={"voice_start_language": "ru"},
),
)
assert "Как к вам обращаться сейчас?" in decision.reply_text
assert "как к вам обращаться сейчас" not in decision.reply_text.lower()
assert "город" in decision.reply_text.lower()
def test_downstream_voice_start_personalizes_greeting_and_finalizes_confirmed_name():
@@ -3176,7 +3177,8 @@ def test_downstream_voice_turn_adds_inline_name_followup_then_finalizes_provided
)
assert first_turn.metadata["customer_name_status"] == "name_not_obtained"
assert "как мне к вам обращаться" in first_turn.reply_text.lower()
assert "как мне к вам обращаться" not in first_turn.reply_text.lower()
assert "город" in first_turn.reply_text.lower()
second_turn = voice_module.turn_voice_session(
seeded["session_id"],
@@ -3212,6 +3214,34 @@ def test_downstream_voice_turn_adds_inline_name_followup_then_finalizes_provided
session.close()
def test_downstream_voice_turn_extracts_explicit_name_without_restarting_name_flow():
seeded = seed_voice_downstream_session(
marker=f"voice_explicit_name_intent_{new_id('seed')}",
name_status="name_not_obtained",
customer_display_name="+77010009999",
)
turn = voice_module.turn_voice_session(
seeded["session_id"],
VoiceAITurnIn(
voice_session_id=seeded["session_id"],
call_id=seeded["call_id"],
interaction_id=seeded["interaction_id"],
transcript_text="Меня зовут Ания, мне нужно узнать график работы",
language="ru",
sequence_no=1,
metadata={"voice_start_language": "ru"},
),
)
assert turn.metadata["customer_name_status"] == "name_obtained"
assert turn.metadata["customer_name_value"] == "Ания"
assert "город" in turn.reply_text.lower()
assert "филиал" in turn.reply_text.lower()
assert "какой вопрос по работе" not in turn.reply_text.lower()
assert "как мне к вам обращаться" not in turn.reply_text.lower()
@pytest.mark.parametrize(
("transcript_text", "expected_name"),
[
+97
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
import time
import httpx
import pytest
from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes
from services.ai_voice_runtime_service.providers import asr as asr_module
@@ -78,6 +81,99 @@ def test_yandex_asr_provider_posts_lpcm_with_tts_credential_fallback(monkeypatch
assert calls[0]["timeout"] == 5.0
def test_elevenlabs_asr_provider_posts_wav_with_tts_credential_fallback(monkeypatch):
calls: list[dict] = []
pcm = b"\x01\x00" * 160
wav_bytes = pcm16le_to_wav_bytes(pcm, sample_rate_hz=8000)
class _DummyClient:
def __init__(self, *, timeout: float) -> None:
self.timeout = timeout
def __enter__(self) -> "_DummyClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def post(
self,
url: str,
*,
headers: dict[str, str],
data: dict[str, str],
files: dict[str, tuple[str, bytes, str]],
) -> _DummyResponse:
calls.append(
{
"url": url,
"headers": headers,
"data": data,
"files": files,
"timeout": self.timeout,
}
)
return _DummyResponse({"text": "schedule", "language_code": "rus"})
monkeypatch.delenv("AI_VOICE_ASR_ELEVENLABS_API_KEY", raising=False)
monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "tts-elevenlabs-key")
monkeypatch.setenv("AI_VOICE_ASR_ELEVENLABS_API_BASE", "https://api.elevenlabs.example")
monkeypatch.setenv("AI_VOICE_ASR_ELEVENLABS_MODEL_ID", "scribe_v1")
monkeypatch.setattr(asr_module.httpx, "Client", _DummyClient)
provider = asr_module.ElevenLabsASRProvider()
result = provider.transcribe(wav_bytes, language_hint="ru")
assert result.text == "schedule"
assert result.language == "ru"
assert len(calls) == 1
assert calls[0]["url"] == "https://api.elevenlabs.example/v1/speech-to-text"
assert calls[0]["headers"]["xi-api-key"] == "tts-elevenlabs-key"
assert calls[0]["data"]["model_id"] == "scribe_v1"
assert calls[0]["data"]["language_code"] == "rus"
assert calls[0]["files"]["file"][0] == "turn.wav"
assert calls[0]["files"]["file"][2] == "audio/wav"
assert calls[0]["timeout"] == 20.0
def test_elevenlabs_asr_provider_surfaces_http_auth_errors(monkeypatch):
request = httpx.Request("POST", "https://api.elevenlabs.example/v1/speech-to-text")
response = httpx.Response(401, request=request)
class _DummyClient:
def __init__(self, *, timeout: float) -> None:
self.timeout = timeout
def __enter__(self) -> "_DummyClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def post(
self,
url: str,
*,
headers: dict[str, str],
data: dict[str, str],
files: dict[str, tuple[str, bytes, str]],
) -> _DummyResponse:
del url, headers, data, files
class _FailingResponse(_DummyResponse):
def raise_for_status(self_nonlocal) -> None:
raise httpx.HTTPStatusError("401 Unauthorized", request=request, response=response)
return _FailingResponse()
monkeypatch.setenv("AI_VOICE_ASR_ELEVENLABS_API_KEY", "asr-elevenlabs-key")
monkeypatch.setattr(asr_module.httpx, "Client", _DummyClient)
provider = asr_module.ElevenLabsASRProvider()
with pytest.raises(httpx.HTTPStatusError):
provider.transcribe(b"\x01\x00" * 160, language_hint="ru")
def test_yandex_asr_provider_uses_iam_token(monkeypatch):
calls: list[dict] = []
@@ -325,6 +421,7 @@ def test_yandex_grpc_streaming_provider_returns_partial_and_final():
def test_yandex_asr_builders():
assert isinstance(asr_module.build_asr_provider("elevenlabs"), asr_module.ElevenLabsASRProvider)
assert isinstance(asr_module.build_asr_provider("yandex"), asr_module.YandexSpeechKitASRProvider)
assert isinstance(asr_module.build_asr_provider("speechkit"), asr_module.YandexSpeechKitASRProvider)
assert isinstance(
+169 -2
View File
@@ -1082,7 +1082,7 @@ def test_voice_ai_summary_exposes_customer_name_state_for_operator(tmp_path):
confidence=0.96,
is_final=True,
barge_in_interrupted=False,
payload_json="{}",
payload_json='{"intent_bearing": true, "provider": "elevenlabs"}',
created_at=now,
),
VoiceTranscriptSegmentRow(
@@ -1097,7 +1097,7 @@ def test_voice_ai_summary_exposes_customer_name_state_for_operator(tmp_path):
confidence=None,
is_final=True,
barge_in_interrupted=False,
payload_json="{}",
payload_json='{"delivery_state": "delivered", "provider": "elevenlabs"}',
created_at=now,
),
]
@@ -1113,6 +1113,173 @@ def test_voice_ai_summary_exposes_customer_name_state_for_operator(tmp_path):
assert summary.customer_name_value == "Айдос"
assert summary.customer_name_source == "voice_start"
assert summary.voice_start_language == "ru"
assert summary.customer_request_text == "Соедините с оператором"
assert summary.ai_outcome_text == "Сейчас переведу вас на оператора."
assert [segment.text for segment in summary.transcript_segments] == [
"Соедините с оператором",
"Сейчас переведу вас на оператора.",
]
def test_voice_ai_summary_uses_intent_bearing_caller_turns_and_delivered_assistant_segments(tmp_path):
now = utc_now_iso()
call_id = f"call_voice_truth_{tmp_path.name}"
session_id = f"avs_voice_truth_{tmp_path.name}"
ai_session_id = f"ais_voice_truth_{tmp_path.name}"
interaction_id = f"int_voice_truth_{tmp_path.name}"
session = get_session()
try:
session.add(
AsteriskCallLinkRow(
call_id=call_id,
linked_id=f"linked_voice_truth_{tmp_path.name}",
queue_code="voice_lab_ai",
queue_id="que_voice_lab",
interaction_id=interaction_id,
caller_number="+77010002233",
caller_name="Truth Caller",
status="active",
telephony_status="connected",
claimed_by_user=None,
claimed_at=None,
operator_extension=None,
channel_name="PJSIP/1001-000111",
started_at=now,
connected_at=now,
ended_at=None,
updated_at=now,
voice_session_id=session_id,
ai_session_id=ai_session_id,
ai_state="active",
ai_handoff_reason=None,
ai_last_model_at=now,
voice_start_language="ru",
)
)
session.add(
AISessionRow(
session_id=ai_session_id,
channel="voice",
call_id=call_id,
thread_id=None,
interaction_id=interaction_id,
customer_id="cus_voice_truth",
agent_profile="voice_support",
language="ru",
status="active",
summary_text="",
last_user_message_id=None,
last_ai_message_id=None,
handoff_reason=None,
created_at=now,
updated_at=now,
closed_at=None,
)
)
session.add(
VoiceAISessionRow(
session_id=session_id,
call_id=call_id,
linked_id=f"linked_voice_truth_{tmp_path.name}",
interaction_id=interaction_id,
customer_id="cus_voice_truth",
queue_id="que_voice_lab",
ai_session_id=ai_session_id,
agent_profile="voice_support",
language="ru",
asr_provider="elevenlabs",
tts_provider="elevenlabs",
status="active",
handoff_reason=None,
handoff_target_queue_id="que_voice_lab",
disclosure_played_at=now,
last_user_utterance_at=now,
last_ai_reply_at=now,
started_at=now,
updated_at=now,
ended_at=None,
voice_start_language="ru",
)
)
session.add_all(
[
VoiceTranscriptSegmentRow(
segment_id=f"{session_id}_seg_1",
session_id=session_id,
call_id=call_id,
interaction_id=interaction_id,
sequence_no=1,
speaker="caller",
source_type="voice_asr",
text="Мне надо узнать график работы.",
confidence=0.96,
is_final=True,
barge_in_interrupted=False,
payload_json='{"intent_bearing": true, "provider": "elevenlabs"}',
created_at=now,
),
VoiceTranscriptSegmentRow(
segment_id=f"{session_id}_seg_2",
session_id=session_id,
call_id=call_id,
interaction_id=interaction_id,
sequence_no=2,
speaker="assistant",
source_type="voice_policy",
text="Подскажите, пожалуйста, город или филиал.",
confidence=None,
is_final=True,
barge_in_interrupted=False,
payload_json='{"delivery_state": "delivered", "provider": "elevenlabs"}',
created_at=now,
),
VoiceTranscriptSegmentRow(
segment_id=f"{session_id}_seg_3",
session_id=session_id,
call_id=call_id,
interaction_id=interaction_id,
sequence_no=3,
speaker="caller",
source_type="voice_asr",
text="Алло, приветствую.",
confidence=0.8,
is_final=True,
barge_in_interrupted=False,
payload_json='{"intent_bearing": false, "provider": "elevenlabs"}',
created_at=now,
),
VoiceTranscriptSegmentRow(
segment_id=f"{session_id}_seg_4",
session_id=session_id,
call_id=call_id,
interaction_id=interaction_id,
sequence_no=4,
speaker="assistant",
source_type="voice_policy",
text="Это прерванный ответ.",
confidence=None,
is_final=True,
barge_in_interrupted=True,
payload_json='{"delivery_state": "interrupted", "provider": "elevenlabs"}',
created_at=now,
),
]
)
session.commit()
finally:
session.close()
summary = bridge_module._voice_ai_summary_for_call(call_id)
assert summary is not None
assert summary.customer_request_text == "Мне надо узнать график работы."
assert summary.ai_outcome_text == "Подскажите, пожалуйста, город или филиал."
assert [(segment.speaker, segment.text) for segment in summary.transcript_segments] == [
("caller", "Мне надо узнать график работы."),
("assistant", "Подскажите, пожалуйста, город или филиал."),
("caller", "Алло, приветствую."),
]
def test_call_started_re_raises_original_create_error_when_all_fallbacks_fail(monkeypatch, tmp_path):
monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}')