fix(voice): suppress low-signal turns and false lookup replies

This commit is contained in:
Yera All
2026-04-12 20:40:25 +05:00
parent 18ab5aa2e2
commit 57bd5cad4b
5 changed files with 323 additions and 11 deletions
+140 -4
View File
@@ -806,6 +806,7 @@ def _voice_is_low_signal_caller_text(text: str | None) -> bool:
"ладно",
"неа",
"нет",
"ой",
"ок",
"понял",
"поняла",
@@ -819,6 +820,21 @@ def _voice_is_low_signal_caller_text(text: str | None) -> bool:
return normalized in low_signal_phrases
def _voice_is_hearing_check_caller_text(text: str | None) -> bool:
normalized = _voice_text_key(text)
if not normalized:
return False
markers = (
"алло ты меня слышишь",
"вы меня слышите",
"меня слышно",
"меня слышишь",
"ты меня слышишь",
"слышишь меня",
)
return any(marker in normalized for marker in markers)
def _voice_is_confused_caller_text(text: str | None) -> bool:
normalized = _voice_text_key(text)
confusion_markers = (
@@ -878,6 +894,25 @@ def _voice_topic_prompt(language: str, caller_texts: list[str]) -> str | None:
if not context:
return None
if any(marker in context for marker in ("график", "время работы", "режим работы", "часы работы", "работаете")):
city_scope = any(
marker in context
for marker in (
"алмат",
"астан",
"в городе",
"город",
"караганд",
"кызылорд",
"павлодар",
"семей",
"тараз",
"шымкент",
)
)
if city_scope:
if language == "kz":
return "Osy qaladagy qaysy filial nemese mekenjai qyzyqtyratynyn aitnyz."
return "Подскажите, какой филиал или адрес в этом городе вас интересует?"
if language == "kz":
return "Qai filialdyn, mekendyng nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz."
return "Подскажите, график работы какого филиала, адреса или города вас интересует?"
@@ -1005,11 +1040,11 @@ def _voice_confusion_prompt(language: str, caller_texts: list[str]) -> str:
topic_prompt = _voice_topic_prompt(language, caller_texts)
if topic_prompt:
if language == "kz":
return f"Qongyrau taqyrybyn naqtylap jatyrmyn. {topic_prompt}"
return f"Сейчас уточняю цель звонка. {topic_prompt}"
return f"Naqtylap alaiyn. {topic_prompt}"
return f"Подскажите точнее. {topic_prompt}"
if language == "kz":
return "Qazir qongyraudyn maqratyn anyqtap jatyrmyn. Qysqasha aitnyz: jumys uaqyty, otinish statusy, tarif nemese operator."
return "Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор."
return "Naqtylap alaiyn: jumys uaqyty, otinish statusy, tarif nemese operator degenniń birin aitnyz."
return "Подскажите точнее: график работы, статус заявки, тариф или оператор."
def _voice_loop_handoff(language: str) -> tuple[str, str, str]:
@@ -1212,6 +1247,79 @@ def _voice_compact_reply_text(text: str, *, language: str) -> str:
return f"{shortened}{ending}"
def _voice_caller_context_before_current(caller_texts: list[str], transcript_text: str) -> list[str]:
if not caller_texts:
return []
current_key = _voice_text_key(transcript_text)
if current_key and _voice_text_key(caller_texts[-1]) == current_key:
return caller_texts[:-1]
return caller_texts
def _voice_is_midcall_greeting_reply(text: str | None) -> bool:
normalized = _voice_text_key(text)
if not normalized:
return False
greeting_markers = ("здравствуйте", "сәлеметсіз", "сәлем")
followup_markers = ("чем помочь", "как я могу помочь", "коротко расскажите", "қалай көмектесе")
return any(marker in normalized for marker in greeting_markers) and any(
marker in normalized for marker in followup_markers
)
def _voice_contains_false_lookup_promise(text: str | None) -> bool:
normalized = _voice_text_key(text)
if not normalized:
return False
markers = (
"сейчас уточню",
"я уточню",
"уточню",
"минуточ",
"подождите",
"подождите пожалуйста",
"проверю",
"сейчас проверю",
"я проверю",
"посмотрю",
"сейчас посмотрю",
)
return any(marker in normalized for marker in markers)
def _voice_hearing_check_reply(language: str, caller_texts: list[str]) -> str:
followup = _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
if language == "kz":
return f"Ia, estip turmyn. {followup}"
return f"Да, вас слышу. {followup}"
def _voice_postprocess_reply_text(
*,
language: str,
transcript_text: str,
transcript_window: list[VoiceTranscriptSegmentRow],
reply_text: str,
kb_results: list[Any],
needs_handoff: bool,
) -> str:
normalized_reply = str(reply_text or "").strip()
if not normalized_reply or needs_handoff:
return normalized_reply
caller_texts = _voice_recent_caller_texts(transcript_window)
prior_caller_texts = _voice_caller_context_before_current(caller_texts, transcript_text)
if _voice_is_hearing_check_caller_text(transcript_text):
return _voice_hearing_check_reply(language, prior_caller_texts or caller_texts)
if _voice_contains_false_lookup_promise(normalized_reply) and not kb_results:
return _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
if _voice_is_midcall_greeting_reply(normalized_reply) and any(
segment.speaker == "assistant" for segment in transcript_window
):
context_texts = prior_caller_texts if _voice_is_low_signal_caller_text(transcript_text) else caller_texts
return _voice_topic_prompt(language, context_texts) or _voice_generic_prompt(language)
return normalized_reply
def _voice_v2_metadata(
transcript_text: str,
request_metadata: dict[str, Any] | None = None,
@@ -1653,6 +1761,26 @@ def _voice_decision(
"latency_ms": 1,
}
if _voice_is_hearing_check_caller_text(normalized):
prior_caller_texts = _voice_caller_context_before_current(caller_texts, transcript_text)
decision = {
"language": language,
"intent": "clarification",
"reply_text": _voice_hearing_check_reply(language, prior_caller_texts or caller_texts),
"confidence": 0.66,
"needs_handoff": False,
"handoff_reason": None,
"case_action": "keep_open",
"kb_refs": [],
"summary_text": "AI подтвердил, что слышит клиента, и продолжил активный сценарий без сброса диалога.",
"model": "voice_policy_hearing_check",
"latency_ms": 1,
}
if v2_metadata:
decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
return decision
clarification_count = _voice_recent_clarification_count(transcript_window)
repeated_reply_count = _voice_repeated_assistant_reply_count(transcript_window)
recent_caller_window = caller_texts[-3:]
@@ -2283,6 +2411,14 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe
customer_name_status=effective_name_status,
request_metadata=request_metadata,
)
decision["reply_text"] = _voice_postprocess_reply_text(
language=decision["language"],
transcript_text=payload.transcript_text,
transcript_window=transcript_window,
reply_text=str(decision.get("reply_text") or ""),
kb_results=kb_results,
needs_handoff=bool(decision.get("needs_handoff")),
)
if decision.get("extracted_name") and not early_plan_only:
effective_name_status = "name_obtained"
@@ -174,7 +174,7 @@ class AudioSocketMediaRuntime:
self._actors: dict[str, MediaActor] = {}
self._v2_ack_wait_seconds = 0.18
self._partial_asr_min_ms = 320
self._immediate_ack_min_ms = 280
self._immediate_ack_min_ms = 700
self._v2_ack_post_gap_seconds = 0.10
self._partial_poll_interval_seconds = 0.20
self._stable_partial_hold_seconds = 0.40
@@ -184,6 +184,32 @@ class AudioSocketMediaRuntime:
def _normalize_intent_text(text: str) -> str:
return " ".join(str(text or "").strip().lower().split())
@classmethod
def _is_low_signal_transcript(cls, text: str | None) -> bool:
normalized = cls._normalize_intent_text(text)
if not normalized:
return True
return normalized in {
"ага",
"алло",
"да",
"добрый день",
"здравствуйте",
"ладно",
"неа",
"нет",
"ой",
"ок",
"понял",
"поняла",
"привет",
"слышу",
"слышно",
"угу",
"хорошо",
"ясно",
}
def _detect_early_intent(self, text: str) -> str:
normalized = self._normalize_intent_text(text)
if not normalized:
@@ -363,8 +389,6 @@ class AudioSocketMediaRuntime:
)
except StreamingASRUnavailable as exc:
actor.asr_streaming_enabled = False
actor.registration.voice_v2_duplex = False
actor.registration.voice_v2_partial_asr = False
logger.warning(
"audiosocket.streaming_asr_unavailable session_id=%s backend=%s error=%s",
actor.registration.voice_session_id,
@@ -913,8 +937,6 @@ class AudioSocketMediaRuntime:
str(exc)[:500],
)
await self._close_streaming_asr(actor)
actor.registration.voice_v2_duplex = False
actor.registration.voice_v2_partial_asr = False
elif actor.registration.voice_v2_partial_asr:
self._maybe_schedule_partial_asr(actor)
if vad_result.utterance_pcm:
@@ -1008,6 +1030,15 @@ class AudioSocketMediaRuntime:
if not transcript_text:
await self._set_actor_state(actor, "listening")
return
if self._is_low_signal_transcript(transcript_text):
actor.finalized_utterance_generation = max(actor.finalized_utterance_generation, utterance_generation)
logger.warning(
"audiosocket.low_signal_ignored session_id=%s transcript=%s",
actor.registration.voice_session_id,
transcript_text[:120],
)
await self._set_actor_state(actor, "listening")
return
actor.finalized_utterance_generation = max(actor.finalized_utterance_generation, utterance_generation)
actor.partial_transcript = transcript_text if actor.registration.voice_v2_partial_asr else None
@@ -236,6 +236,19 @@ def _get_engine() -> TranscriptionEngine:
return _ENGINE_INSTANCE
def _warmup_engine() -> None:
try:
_get_engine()
LOGGER.info("streaming_asr.engine_ready")
except Exception as exc: # noqa: BLE001
LOGGER.warning("streaming_asr.engine_warmup_failed error=%s", str(exc)[:500])
@app.on_event("startup")
def _startup_warmup_engine() -> None:
threading.Thread(target=_warmup_engine, daemon=True).start()
@dataclass(slots=True)
class StreamState:
stream_id: str