feat(voice): add elevenlabs stt and transcript truth fixes
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
@@ -268,6 +269,7 @@ def _normalize_name_candidate(text: str | None) -> str | None:
|
||||
return None
|
||||
invalid_tokens = {
|
||||
"\u0434\u0430",
|
||||
"\u0445\u0430\u0447\u0443",
|
||||
"\u043d\u0435\u0442",
|
||||
"\u0430\u043b\u043b\u043e",
|
||||
"\u043f\u0440\u0438\u0432\u0435\u0442",
|
||||
@@ -286,6 +288,16 @@ def _normalize_name_candidate(text: str | None) -> str | None:
|
||||
}
|
||||
if any(_voice_text_key(word) in invalid_tokens for word in filtered):
|
||||
return None
|
||||
if len(filtered) > 2:
|
||||
return None
|
||||
if len(filtered) == 2:
|
||||
similarity = difflib.SequenceMatcher(
|
||||
None,
|
||||
_voice_text_key(filtered[0]),
|
||||
_voice_text_key(filtered[1]),
|
||||
).ratio()
|
||||
if similarity >= 0.72:
|
||||
return None
|
||||
return _canonical_name(" ".join(filtered))
|
||||
|
||||
|
||||
@@ -316,6 +328,7 @@ def _extract_name_candidate(text: str | None, language: str) -> tuple[str | None
|
||||
if not raw:
|
||||
return None, False
|
||||
normalized = _voice_text_key(raw)
|
||||
service_topic = _voice_has_service_topic(raw)
|
||||
explicit_patterns = [
|
||||
r"(?:\u043c\u0435\u043d\u044f\s+\u0437\u043e\u0432\u0443\u0442|my name is|i am|this is)\s+(.+)",
|
||||
r"(?:\u044f|it's me)\s+(.+)",
|
||||
@@ -331,7 +344,15 @@ def _extract_name_candidate(text: str | None, language: str) -> tuple[str | None
|
||||
cutoff_tokens = {
|
||||
"\u0445\u043e\u0442\u0435\u043b",
|
||||
"\u0445\u043e\u0447\u0443",
|
||||
"\u043c\u043d\u0435",
|
||||
"\u043d\u0430\u0434\u043e",
|
||||
"\u043d\u0443\u0436\u043d\u043e",
|
||||
"\u0443\u0437\u043d\u0430\u0442\u044c",
|
||||
"\u0433\u0440\u0430\u0444\u0438\u043a",
|
||||
"\u0440\u0430\u0431\u043e\u0442\u044b",
|
||||
"\u0430\u0434\u0440\u0435\u0441",
|
||||
"\u0444\u0438\u043b\u0438\u0430\u043b",
|
||||
"\u0433\u043e\u0440\u043e\u0434",
|
||||
"\u0442\u0430\u0440\u0438\u0444",
|
||||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||||
@@ -349,10 +370,12 @@ def _extract_name_candidate(text: str | None, language: str) -> tuple[str | None
|
||||
break
|
||||
candidate = _normalize_name_candidate(" ".join(candidate_words)) or _normalize_name_candidate(tail)
|
||||
if candidate:
|
||||
return candidate, _name_followup_needed(raw)
|
||||
return candidate, False
|
||||
|
||||
candidate = _normalize_name_candidate(raw)
|
||||
if candidate:
|
||||
if service_topic:
|
||||
return None, False
|
||||
lower_cand = candidate.lower()
|
||||
stopwords = {
|
||||
"здравствуйте", "привет", "алло", "да", "нет", "добрый", "день",
|
||||
@@ -482,6 +505,26 @@ def _voice_explicit_name_candidate(text: str | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
normalized = _voice_text_key(raw)
|
||||
cutoff_tokens = {
|
||||
"\u043c\u043d\u0435",
|
||||
"\u043d\u0430\u0434\u043e",
|
||||
"\u043d\u0443\u0436\u043d\u043e",
|
||||
"\u0445\u043e\u0447\u0443",
|
||||
"\u0445\u043e\u0442\u0435\u043b",
|
||||
"\u0443\u0437\u043d\u0430\u0442\u044c",
|
||||
"\u0433\u0440\u0430\u0444\u0438\u043a",
|
||||
"\u0440\u0430\u0431\u043e\u0442\u044b",
|
||||
"\u0430\u0434\u0440\u0435\u0441",
|
||||
"\u0444\u0438\u043b\u0438\u0430\u043b",
|
||||
"\u0433\u043e\u0440\u043e\u0434",
|
||||
"\u0442\u0430\u0440\u0438\u0444",
|
||||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||||
"\u0432\u043e\u043f\u0440\u043e\u0441",
|
||||
"\u043a\u0435\u0440\u0435\u043a",
|
||||
"\u0441\u04b1\u0440\u0430\u0493",
|
||||
"\u043a\u04e9\u043c\u0435\u043a",
|
||||
}
|
||||
patterns = (
|
||||
r"(?:меня зовут|мое имя|это|my name is|i am|this is)\s+(.+)",
|
||||
r"(?:менің атым|аты[мң]?|mening atym)\s+(.+)",
|
||||
@@ -491,7 +534,15 @@ def _voice_explicit_name_candidate(text: str | None) -> str | None:
|
||||
if not match:
|
||||
continue
|
||||
tail = match.group(1).strip()
|
||||
candidate = _normalize_name_candidate(tail)
|
||||
tail_words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", tail, flags=re.UNICODE)
|
||||
candidate_words: list[str] = []
|
||||
for word in tail_words:
|
||||
if _voice_text_key(word) in cutoff_tokens:
|
||||
break
|
||||
candidate_words.append(word)
|
||||
if len(candidate_words) >= 3:
|
||||
break
|
||||
candidate = _normalize_name_candidate(" ".join(candidate_words)) or _normalize_name_candidate(tail)
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
@@ -663,6 +714,7 @@ def _voice_downstream_name_update(
|
||||
source = str(current_source or "none").strip() or "none"
|
||||
resolved_at = str(current_resolved_at or "").strip() or None
|
||||
action = "ignored"
|
||||
service_topic = _voice_has_service_topic(transcript_text)
|
||||
|
||||
explicit_candidate = _voice_explicit_name_candidate(transcript_text)
|
||||
candidate, needs_followup = _extract_name_candidate(transcript_text, language)
|
||||
@@ -684,9 +736,9 @@ def _voice_downstream_name_update(
|
||||
elif name_value and _voice_is_name_confirmation(transcript_text, name_value):
|
||||
action = "confirm"
|
||||
candidate = name_value
|
||||
elif candidate and name_value and candidate_key and candidate_key != current_key:
|
||||
elif candidate and name_value and candidate_key and candidate_key != current_key and (explicit_candidate or not service_topic):
|
||||
action = "correct"
|
||||
elif candidate:
|
||||
elif candidate and (explicit_candidate or not service_topic):
|
||||
action = "provide" if not name_value else "confirm"
|
||||
candidate = candidate or name_value
|
||||
elif status == "name_obtained":
|
||||
@@ -694,7 +746,7 @@ def _voice_downstream_name_update(
|
||||
else:
|
||||
if explicit_candidate:
|
||||
action = "provide"
|
||||
elif candidate and not needs_followup:
|
||||
elif candidate and not needs_followup and not service_topic:
|
||||
action = "provide"
|
||||
|
||||
finalizable = False
|
||||
@@ -729,6 +781,7 @@ def _voice_downstream_name_update(
|
||||
and action == "ignored"
|
||||
and not _voice_name_followup_asked(transcript_window, language, config)
|
||||
and not _voice_is_low_signal_caller_text(transcript_text)
|
||||
and not service_topic
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
|
||||
@@ -333,6 +333,107 @@ def _record_segment(
|
||||
)
|
||||
|
||||
|
||||
def _normalize_voice_text_key(text: str | None) -> str:
|
||||
compact = str(text or "").strip().lower()
|
||||
for char in ",.!?;:\"'()[]{}":
|
||||
compact = compact.replace(char, " ")
|
||||
return " ".join(compact.split())
|
||||
|
||||
|
||||
def _is_low_signal_voice_text(text: str | None) -> bool:
|
||||
normalized = _normalize_voice_text_key(text)
|
||||
if not normalized:
|
||||
return True
|
||||
return normalized in {
|
||||
"\u0430\u043b\u043b\u043e",
|
||||
"\u0430\u0433\u0430",
|
||||
"\u0434\u0430",
|
||||
"\u0434\u043e\u0431\u0440\u044b\u0439 \u0434\u0435\u043d\u044c",
|
||||
"\u0437\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435",
|
||||
"\u043b\u0430\u0434\u043d\u043e",
|
||||
"\u043d\u0435\u0442",
|
||||
"\u043d\u0435\u0430",
|
||||
"\u043e\u0439",
|
||||
"\u043e\u043a",
|
||||
"\u043f\u0440\u0438\u0432\u0435\u0442",
|
||||
"\u0441\u043b\u044b\u0448\u043d\u043e",
|
||||
"\u0441\u043b\u044b\u0448\u0443",
|
||||
"\u0443\u0433\u0443",
|
||||
"\u0445\u043e\u0440\u043e\u0448\u043e",
|
||||
"\u044f\u0441\u043d\u043e",
|
||||
}
|
||||
|
||||
|
||||
def _is_intent_bearing_turn(transcript_text: str | None, intent: str | None) -> bool:
|
||||
normalized_intent = str(intent or "").strip().lower()
|
||||
if normalized_intent in {
|
||||
"schedule",
|
||||
"address",
|
||||
"price",
|
||||
"status",
|
||||
"problem",
|
||||
"operator_request",
|
||||
"handoff_request",
|
||||
"kb_answer",
|
||||
}:
|
||||
return True
|
||||
normalized_text = _normalize_voice_text_key(transcript_text)
|
||||
if not normalized_text or _is_low_signal_voice_text(normalized_text):
|
||||
return False
|
||||
markers = (
|
||||
"\u0433\u0440\u0430\u0444\u0438\u043a",
|
||||
"\u0432\u0440\u0435\u043c\u044f \u0440\u0430\u0431\u043e\u0442\u044b",
|
||||
"\u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b",
|
||||
"\u0430\u0434\u0440\u0435\u0441",
|
||||
"\u0444\u0438\u043b\u0438\u0430\u043b",
|
||||
"\u0433\u043e\u0440\u043e\u0434",
|
||||
"\u0437\u0430\u044f\u0432\u043a",
|
||||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||||
"\u0442\u0430\u0440\u0438\u0444",
|
||||
"\u0446\u0435\u043d\u0430",
|
||||
"\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c",
|
||||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||||
"\u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440",
|
||||
"\u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442",
|
||||
"\u043e\u0448\u0438\u0431\u043a",
|
||||
"\u043f\u0440\u043e\u0431\u043b\u0435\u043c",
|
||||
)
|
||||
return any(marker in normalized_text for marker in markers)
|
||||
|
||||
|
||||
def _annotate_latest_caller_segment(
|
||||
session,
|
||||
*,
|
||||
session_id: str,
|
||||
provider_name: str,
|
||||
transcript_text: str,
|
||||
intent: str | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> None:
|
||||
row = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == session_id)
|
||||
.where(VoiceTranscriptSegmentRow.speaker == "caller")
|
||||
.order_by(VoiceTranscriptSegmentRow.id.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return
|
||||
try:
|
||||
payload = json.loads(row.payload_json or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
payload["provider"] = str(provider_name or "").strip() or payload.get("provider")
|
||||
payload["intent_bearing"] = _is_intent_bearing_turn(transcript_text, intent)
|
||||
if intent:
|
||||
payload["decision_intent"] = str(intent).strip()
|
||||
if isinstance(metadata, dict) and metadata:
|
||||
merged_metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
merged_metadata.update(metadata)
|
||||
payload["metadata"] = merged_metadata
|
||||
row.payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def _load_greeting_segment(session, session_id: str) -> VoiceTranscriptSegmentRow | None:
|
||||
rows = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
@@ -379,7 +480,12 @@ def _ensure_greeting_segment(session, voice_session: VoiceAISessionRow, greeting
|
||||
source_type="tts",
|
||||
text=greeting_text,
|
||||
sequence_no=_next_sequence(session, voice_session.session_id),
|
||||
payload={"kind": "greeting", "delivery_status": "planned"},
|
||||
payload={
|
||||
"kind": "greeting",
|
||||
"provider": _current_tts_provider_name(),
|
||||
"delivery_status": "planned",
|
||||
"delivery_state": "planned",
|
||||
},
|
||||
is_final=False,
|
||||
)
|
||||
|
||||
@@ -399,6 +505,8 @@ def _upsert_greeting_segment(session, voice_session: VoiceAISessionRow, greeting
|
||||
payload = {}
|
||||
payload["kind"] = "greeting"
|
||||
payload.setdefault("delivery_status", "planned")
|
||||
payload.setdefault("delivery_state", str(payload.get("delivery_status") or "planned"))
|
||||
payload.setdefault("provider", _current_tts_provider_name())
|
||||
row.payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
row.is_final = False
|
||||
|
||||
@@ -414,7 +522,12 @@ def _queue_new_greeting_segment(session, voice_session: VoiceAISessionRow, greet
|
||||
source_type="tts",
|
||||
text=normalized,
|
||||
sequence_no=_next_sequence(session, voice_session.session_id),
|
||||
payload={"kind": "greeting", "delivery_status": "planned"},
|
||||
payload={
|
||||
"kind": "greeting",
|
||||
"provider": _current_tts_provider_name(),
|
||||
"delivery_status": "planned",
|
||||
"delivery_state": "planned",
|
||||
},
|
||||
is_final=False,
|
||||
)
|
||||
|
||||
@@ -492,6 +605,7 @@ def _mark_last_assistant_segment_interrupted(session, session_id: str) -> None:
|
||||
except Exception:
|
||||
payload = {}
|
||||
payload["delivery_status"] = "interrupted"
|
||||
payload["delivery_state"] = "interrupted"
|
||||
row.payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -718,6 +832,8 @@ def _update_reply_delivery_status(
|
||||
continue
|
||||
matched_row = row
|
||||
payload["delivery_status"] = status
|
||||
payload["delivery_state"] = status
|
||||
payload.setdefault("provider", _current_tts_provider_name())
|
||||
row.payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
break
|
||||
if matched_row is not None:
|
||||
@@ -844,7 +960,9 @@ def _record_runtime_reply_planned(
|
||||
text=normalized_text,
|
||||
sequence_no=_next_sequence(session, voice_session.session_id),
|
||||
payload={
|
||||
"provider": _current_tts_provider_name(),
|
||||
"delivery_status": "planned",
|
||||
"delivery_state": "planned",
|
||||
"kind": kind,
|
||||
"metadata": metadata or {},
|
||||
},
|
||||
@@ -1065,7 +1183,12 @@ def _process_voice_ai_turn_sync(
|
||||
source_type="asr",
|
||||
text=payload.transcript_text,
|
||||
sequence_no=max(payload.sequence_no, _next_sequence(session, voice_session.session_id)),
|
||||
payload={"metadata": payload_metadata, "barge_in": payload.barge_in},
|
||||
payload={
|
||||
"provider": _ASR_PROVIDER.name,
|
||||
"metadata": payload_metadata,
|
||||
"barge_in": payload.barge_in,
|
||||
"intent_bearing": False,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -1080,6 +1203,14 @@ def _process_voice_ai_turn_sync(
|
||||
voice_session.language = decision.language or voice_session.language
|
||||
voice_session.handoff_reason = decision.handoff_reason
|
||||
_apply_voice_start_metadata(voice_session, decision.metadata)
|
||||
_annotate_latest_caller_segment(
|
||||
session,
|
||||
session_id=voice_session.session_id,
|
||||
provider_name=_ASR_PROVIDER.name,
|
||||
transcript_text=payload.transcript_text,
|
||||
intent=decision.intent,
|
||||
metadata=payload_metadata,
|
||||
)
|
||||
voice_session.status = decision.status or ("handoff_requested" if decision.needs_handoff else "active")
|
||||
voice_session.updated_at = utc_now_iso()
|
||||
if decision.reply_text and not bool(payload_metadata.get("runtime_defer_reply_planned")):
|
||||
@@ -1094,7 +1225,9 @@ def _process_voice_ai_turn_sync(
|
||||
"intent": decision.intent,
|
||||
"kb_refs": decision.kb_refs,
|
||||
"model": decision.model,
|
||||
"provider": _current_tts_provider_name(),
|
||||
"delivery_status": "planned",
|
||||
"delivery_state": "planned",
|
||||
"metadata": decision.metadata,
|
||||
},
|
||||
is_final=False,
|
||||
|
||||
@@ -254,6 +254,19 @@ class AudioSocketMediaRuntime:
|
||||
return "problem"
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _asr_failure_category(exc: Exception) -> str:
|
||||
text = str(exc or "").lower()
|
||||
if "unauthorized" in text or "unknown api key" in text or "invalid api key" in text:
|
||||
return "auth"
|
||||
if "timeout" in text:
|
||||
return "timeout"
|
||||
if "not found" in text:
|
||||
return "provider_not_found"
|
||||
if "service unavailable" in text or "temporarily unavailable" in text:
|
||||
return "provider_unavailable"
|
||||
return "provider_error"
|
||||
|
||||
@staticmethod
|
||||
def _ack_kind_for_intent(intent: str) -> str:
|
||||
if intent == "operator_request":
|
||||
@@ -1223,10 +1236,12 @@ class AudioSocketMediaRuntime:
|
||||
"queue_code": actor.registration.queue_code,
|
||||
"reply_phase": actor.current_reply_phase or "error_handoff",
|
||||
"error_class": exc.__class__.__name__,
|
||||
"error_category": self._asr_failure_category(exc),
|
||||
}
|
||||
logger.warning(
|
||||
"audiosocket.turn_failed session_id=%s error=%s",
|
||||
"audiosocket.turn_failed session_id=%s category=%s error=%s",
|
||||
actor.registration.voice_session_id,
|
||||
metadata["error_category"],
|
||||
error_text,
|
||||
)
|
||||
await self._set_actor_state(actor, "handoff_requested", error_text)
|
||||
@@ -1326,6 +1341,14 @@ class AudioSocketMediaRuntime:
|
||||
language_hint=actor.registration.language,
|
||||
)
|
||||
transcript_text = str(transcription.text or "").strip() or partial_transcript
|
||||
logger.info(
|
||||
"audiosocket.asr_turn_ready session_id=%s provider=%s utterance_ms=%s text_len=%s empty=%s",
|
||||
actor.registration.voice_session_id,
|
||||
getattr(self._asr_provider, "name", "unknown"),
|
||||
int(len(pcm_bytes) / 16),
|
||||
len(str(transcript_text or "").strip()),
|
||||
not bool(str(transcript_text or "").strip()),
|
||||
)
|
||||
if not transcript_text:
|
||||
await self._set_actor_state(actor, "listening")
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import audioop
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
@@ -12,6 +13,7 @@ from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
|
||||
from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le
|
||||
from services.shared.security import issue_app_token
|
||||
|
||||
|
||||
@@ -36,6 +38,67 @@ def _openai_asr_model() -> str:
|
||||
return os.getenv("AI_VOICE_ASR_MODEL", "gpt-4o-mini-transcribe").strip() or "gpt-4o-mini-transcribe"
|
||||
|
||||
|
||||
def _elevenlabs_asr_api_base() -> str:
|
||||
return (
|
||||
os.getenv("AI_VOICE_ASR_ELEVENLABS_API_BASE", "https://api.elevenlabs.io").strip()
|
||||
or "https://api.elevenlabs.io"
|
||||
).rstrip("/")
|
||||
|
||||
|
||||
def _elevenlabs_asr_api_key() -> str:
|
||||
return (
|
||||
os.getenv("AI_VOICE_ASR_ELEVENLABS_API_KEY", "").strip()
|
||||
or os.getenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _elevenlabs_asr_model_id() -> str:
|
||||
return os.getenv("AI_VOICE_ASR_ELEVENLABS_MODEL_ID", "scribe_v1").strip() or "scribe_v1"
|
||||
|
||||
|
||||
def _elevenlabs_asr_default_language() -> str:
|
||||
return os.getenv("AI_VOICE_ASR_ELEVENLABS_LANGUAGE", "ru").strip() or "ru"
|
||||
|
||||
|
||||
def _normalize_elevenlabs_asr_language(language: str | None) -> str:
|
||||
raw = str(language or "").strip().lower().replace("_", "-")
|
||||
if not raw:
|
||||
raw = _elevenlabs_asr_default_language().strip().lower().replace("_", "-")
|
||||
mapping = {
|
||||
"ru": "rus",
|
||||
"ru-ru": "rus",
|
||||
"kk": "kaz",
|
||||
"kz": "kaz",
|
||||
"kk-kz": "kaz",
|
||||
"kz-kz": "kaz",
|
||||
"en": "eng",
|
||||
"en-us": "eng",
|
||||
"en-gb": "eng",
|
||||
}
|
||||
return mapping.get(raw, raw or "rus")
|
||||
|
||||
|
||||
def _elevenlabs_asr_sample_rate_hz() -> int:
|
||||
raw = os.getenv("AI_VOICE_ASR_ELEVENLABS_SAMPLE_RATE_HZ", "16000").strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
value = 16000
|
||||
if value < 8000 or value > 48000:
|
||||
return 16000
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_elevenlabs_asr_language(language_code: str | None, language_hint: str | None) -> str | None:
|
||||
normalized = str(language_code or "").strip().lower()
|
||||
if normalized in {"rus", "ru", "ru-ru"}:
|
||||
return "ru"
|
||||
if normalized in {"kaz", "kk", "kz", "kk-kz", "kz-kz"}:
|
||||
return "kz"
|
||||
resolved = str(language_code or language_hint or "").strip()
|
||||
return resolved or language_hint
|
||||
|
||||
|
||||
def _yandex_asr_api_base() -> str:
|
||||
explicit = os.getenv("AI_VOICE_ASR_YANDEX_API_BASE", "").strip()
|
||||
if explicit:
|
||||
@@ -286,6 +349,91 @@ class OpenAIASRProvider(ASRProvider):
|
||||
return self.transcribe(audio_bytes, language_hint=language_hint)
|
||||
|
||||
|
||||
class ElevenLabsASRProvider(ASRProvider):
|
||||
name = "elevenlabs"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_base: str | None = None,
|
||||
api_key: str | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
model_id: str | None = None,
|
||||
sample_rate_hz: int | None = None,
|
||||
) -> None:
|
||||
self._api_base = str(api_base or _elevenlabs_asr_api_base()).strip().rstrip("/")
|
||||
self._api_key = str(api_key if api_key is not None else _elevenlabs_asr_api_key()).strip()
|
||||
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0)
|
||||
self._model_id = str(model_id or _elevenlabs_asr_model_id()).strip() or _elevenlabs_asr_model_id()
|
||||
self._sample_rate_hz = int(sample_rate_hz if sample_rate_hz is not None else _elevenlabs_asr_sample_rate_hz())
|
||||
|
||||
@staticmethod
|
||||
def _pcm_from_audio_bytes(audio_bytes: bytes, *, default_sample_rate_hz: int) -> tuple[bytes, int]:
|
||||
if not audio_bytes:
|
||||
return b"", default_sample_rate_hz
|
||||
try:
|
||||
with wave.open(io.BytesIO(audio_bytes), "rb") as wav_file:
|
||||
sample_width = wav_file.getsampwidth()
|
||||
channels = wav_file.getnchannels()
|
||||
sample_rate = wav_file.getframerate()
|
||||
pcm_bytes = wav_file.readframes(wav_file.getnframes())
|
||||
if sample_width != 2:
|
||||
return audio_bytes, default_sample_rate_hz
|
||||
if channels == 2:
|
||||
pcm_bytes = audioop.tomono(pcm_bytes, sample_width, 0.5, 0.5) # type: ignore[name-defined]
|
||||
elif channels != 1:
|
||||
return audio_bytes, default_sample_rate_hz
|
||||
return pcm_bytes, int(sample_rate or default_sample_rate_hz)
|
||||
except (wave.Error, EOFError):
|
||||
return audio_bytes, default_sample_rate_hz
|
||||
|
||||
def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription:
|
||||
if not audio_bytes:
|
||||
return ASRTranscription(text="", language=language_hint, confidence=None)
|
||||
if not self._api_key:
|
||||
raise RuntimeError(
|
||||
"AI_VOICE_ASR_ELEVENLABS_API_KEY or AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs ASR"
|
||||
)
|
||||
|
||||
pcm_bytes, input_sample_rate_hz = self._pcm_from_audio_bytes(
|
||||
audio_bytes,
|
||||
default_sample_rate_hz=self._sample_rate_hz,
|
||||
)
|
||||
if not pcm_bytes:
|
||||
return ASRTranscription(text="", language=language_hint, confidence=None)
|
||||
normalized_sample_rate_hz = self._sample_rate_hz
|
||||
if input_sample_rate_hz != normalized_sample_rate_hz:
|
||||
pcm_bytes = resample_pcm16le(
|
||||
pcm_bytes,
|
||||
input_rate_hz=input_sample_rate_hz,
|
||||
output_rate_hz=normalized_sample_rate_hz,
|
||||
)
|
||||
wav_bytes = pcm16le_to_wav_bytes(pcm_bytes, sample_rate_hz=normalized_sample_rate_hz)
|
||||
language_code = _normalize_elevenlabs_asr_language(language_hint)
|
||||
with httpx.Client(timeout=self._timeout_seconds) as client:
|
||||
response = client.post(
|
||||
f"{self._api_base}/v1/speech-to-text",
|
||||
headers={"xi-api-key": self._api_key},
|
||||
data={
|
||||
"model_id": self._model_id,
|
||||
"language_code": language_code,
|
||||
},
|
||||
files={"file": ("turn.wav", wav_bytes, "audio/wav")},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
transcript_text = str(payload.get("text") or payload.get("transcript") or "").strip()
|
||||
resolved_language = _resolve_elevenlabs_asr_language(payload.get("language_code"), language_hint)
|
||||
return ASRTranscription(
|
||||
text=transcript_text,
|
||||
language=resolved_language,
|
||||
confidence=None,
|
||||
)
|
||||
|
||||
def transcribe_partial(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription:
|
||||
return self.transcribe(audio_bytes, language_hint=language_hint)
|
||||
|
||||
|
||||
class YandexSpeechKitASRProvider(ASRProvider):
|
||||
name = "yandex"
|
||||
|
||||
@@ -985,6 +1133,8 @@ class YandexSpeechKitBufferedStreamingASRProvider(StreamingASRProvider):
|
||||
|
||||
def build_asr_provider(name: str) -> ASRProvider:
|
||||
normalized = str(name or "stub").strip().lower()
|
||||
if normalized == "elevenlabs":
|
||||
return ElevenLabsASRProvider()
|
||||
if normalized == "openai":
|
||||
return OpenAIASRProvider()
|
||||
if normalized in {"yandex", "yandex_speechkit", "speechkit"}:
|
||||
|
||||
@@ -615,6 +615,117 @@ def _next_transcript_sequence(session, session_id: str) -> int:
|
||||
return next_transcript_sequence(session, session_id)
|
||||
|
||||
|
||||
def _segment_payload(row: VoiceTranscriptSegmentRow) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(row.payload_json or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _segment_delivery_state(row: VoiceTranscriptSegmentRow) -> str | None:
|
||||
payload = _segment_payload(row)
|
||||
raw = payload.get("delivery_state") or payload.get("delivery_status")
|
||||
if raw:
|
||||
return str(raw).strip().lower() or None
|
||||
if row.speaker == "assistant" and row.is_final and not row.barge_in_interrupted:
|
||||
return "delivered"
|
||||
return None
|
||||
|
||||
|
||||
def _caller_low_signal_text(text: str | None) -> bool:
|
||||
normalized = " ".join(str(text or "").strip().lower().replace(",", " ").replace(".", " ").split())
|
||||
if not normalized:
|
||||
return True
|
||||
return normalized in {
|
||||
"\u0430\u0433\u0430",
|
||||
"\u0430\u043b\u043b\u043e",
|
||||
"\u0434\u0430",
|
||||
"\u0434\u043e\u0431\u0440\u044b\u0439 \u0434\u0435\u043d\u044c",
|
||||
"\u0437\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435",
|
||||
"\u043b\u0430\u0434\u043d\u043e",
|
||||
"\u043d\u0435\u0442",
|
||||
"\u043d\u0435\u0430",
|
||||
"\u043e\u0439",
|
||||
"\u043e\u043a",
|
||||
"\u043f\u0440\u0438\u0432\u0435\u0442",
|
||||
"\u0441\u043b\u044b\u0448\u043d\u043e",
|
||||
"\u0441\u043b\u044b\u0448\u0443",
|
||||
"\u0443\u0433\u0443",
|
||||
"\u0445\u043e\u0440\u043e\u0448\u043e",
|
||||
"\u044f\u0441\u043d\u043e",
|
||||
}
|
||||
|
||||
|
||||
def _caller_segment_intent_bearing(row: VoiceTranscriptSegmentRow) -> bool:
|
||||
payload = _segment_payload(row)
|
||||
marker = payload.get("intent_bearing")
|
||||
if isinstance(marker, bool):
|
||||
return marker
|
||||
text = str(row.text or "").strip().lower()
|
||||
if _caller_low_signal_text(text):
|
||||
return False
|
||||
markers = (
|
||||
"\u0433\u0440\u0430\u0444\u0438\u043a",
|
||||
"\u0432\u0440\u0435\u043c\u044f \u0440\u0430\u0431\u043e\u0442\u044b",
|
||||
"\u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b",
|
||||
"\u0430\u0434\u0440\u0435\u0441",
|
||||
"\u0444\u0438\u043b\u0438\u0430\u043b",
|
||||
"\u0433\u043e\u0440\u043e\u0434",
|
||||
"\u0437\u0430\u044f\u0432\u043a",
|
||||
"\u0441\u0442\u0430\u0442\u0443\u0441",
|
||||
"\u0442\u0430\u0440\u0438\u0444",
|
||||
"\u0446\u0435\u043d\u0430",
|
||||
"\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c",
|
||||
"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
|
||||
"\u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440",
|
||||
"\u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442",
|
||||
"\u043e\u0448\u0438\u0431\u043a",
|
||||
"\u043f\u0440\u043e\u0431\u043b\u0435\u043c",
|
||||
"\u0445\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c",
|
||||
"\u043c\u043d\u0435 \u043d\u0430\u0434\u043e",
|
||||
"\u043c\u043d\u0435 \u043d\u0443\u0436\u043d\u043e",
|
||||
)
|
||||
return any(marker in text for marker in markers)
|
||||
|
||||
|
||||
def _latest_customer_request_text(session, *, voice_session_id: str) -> str | None:
|
||||
rows = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
|
||||
.where(VoiceTranscriptSegmentRow.speaker == "caller")
|
||||
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
|
||||
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc(), VoiceTranscriptSegmentRow.id.desc())
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return None
|
||||
for row in rows:
|
||||
text = _truncate(row.text, 4000)
|
||||
if text and _caller_segment_intent_bearing(row):
|
||||
return text
|
||||
for row in rows:
|
||||
text = _truncate(row.text, 4000)
|
||||
if text and not _caller_low_signal_text(text):
|
||||
return text
|
||||
return _truncate(rows[0].text, 4000)
|
||||
|
||||
|
||||
def _latest_delivered_assistant_text(session, *, voice_session_id: str) -> str | None:
|
||||
rows = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
|
||||
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
|
||||
.order_by(VoiceTranscriptSegmentRow.sequence_no.desc(), VoiceTranscriptSegmentRow.id.desc())
|
||||
).scalars().all()
|
||||
for row in rows:
|
||||
if _segment_delivery_state(row) != "delivered":
|
||||
continue
|
||||
text = _truncate(row.text, 4000)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _latest_segment_text(
|
||||
session,
|
||||
*,
|
||||
@@ -622,6 +733,23 @@ def _latest_segment_text(
|
||||
speaker: str,
|
||||
final_only: bool = False,
|
||||
) -> str | None:
|
||||
normalized_speaker = str(speaker or "").strip().lower()
|
||||
if normalized_speaker == "caller":
|
||||
return _latest_customer_request_text(session, voice_session_id=voice_session_id)
|
||||
if normalized_speaker == "assistant":
|
||||
if final_only:
|
||||
return _latest_delivered_assistant_text(session, voice_session_id=voice_session_id)
|
||||
rows = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
|
||||
.where(VoiceTranscriptSegmentRow.speaker == "assistant")
|
||||
.order_by(VoiceTranscriptSegmentRow.id.desc())
|
||||
).scalars().all()
|
||||
for row in rows:
|
||||
text = _truncate(row.text, 4000)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
query = (
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
|
||||
@@ -629,9 +757,7 @@ def _latest_segment_text(
|
||||
)
|
||||
if final_only:
|
||||
query = query.where(VoiceTranscriptSegmentRow.is_final.is_(True))
|
||||
row = session.execute(
|
||||
query.order_by(VoiceTranscriptSegmentRow.id.desc()).limit(1)
|
||||
).scalar_one_or_none()
|
||||
row = session.execute(query.order_by(VoiceTranscriptSegmentRow.id.desc()).limit(1)).scalar_one_or_none()
|
||||
return _truncate(row.text if row else None, 4000)
|
||||
|
||||
|
||||
@@ -643,7 +769,6 @@ def _summary_transcript_segments(
|
||||
rows = session.execute(
|
||||
select(VoiceTranscriptSegmentRow)
|
||||
.where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
|
||||
.where(VoiceTranscriptSegmentRow.is_final.is_(True))
|
||||
.order_by(VoiceTranscriptSegmentRow.sequence_no.asc(), VoiceTranscriptSegmentRow.id.asc())
|
||||
).scalars().all()
|
||||
result: list[VoiceAISummaryTranscriptSegmentOut] = []
|
||||
@@ -652,7 +777,13 @@ def _summary_transcript_segments(
|
||||
if not text:
|
||||
continue
|
||||
speaker = str(row.speaker or "").strip().lower()
|
||||
if speaker not in {"caller", "assistant"}:
|
||||
if speaker == "caller":
|
||||
if not row.is_final:
|
||||
continue
|
||||
elif speaker == "assistant":
|
||||
if _segment_delivery_state(row) != "delivered":
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
result.append(
|
||||
VoiceAISummaryTranscriptSegmentOut(
|
||||
|
||||
Reference in New Issue
Block a user