From fe9b3f3a806410630a48784b5167bdbfecb2b306 Mon Sep 17 00:00:00 2001 From: didar Date: Sun, 30 Aug 2026 11:53:56 +0500 Subject: [PATCH] feat: enhance voice reply logic to prevent duplicate name addressing and improve greeting handling --- services/ai_orchestrator_service/voice.py | 58 +++++++++++++++++++++-- tests/test_ai_orchestrator_service.py | 55 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py index e9ecccc..7bf46d8 100644 --- a/services/ai_orchestrator_service/voice.py +++ b/services/ai_orchestrator_service/voice.py @@ -603,20 +603,60 @@ def _voice_has_name_correction(text: str | None) -> bool: return any(marker in normalized for marker in correction_markers) -def _voice_reply_with_name(language: str, reply_text: str, name: str | None) -> str: +def _voice_reply_already_names_customer(reply_text: str, normalized_short: str) -> bool: + """Whether `reply_text` already addresses the customer by name. + + Russian and Kazakh names decline grammatically (e.g. "Данияр" -> "Данияре", + "Данияру", "Данияра"), so the model's own reply often names the customer in + an inflected form that never exact-matches `normalized_short`. An exact + token match alone misses that and this function then goes on to prepend + the name a second time, producing a literal "Данияр, Здравствуйте, Данияре!" + duplicate. Matching on a stem prefix instead of the full token catches any + declined form of the same name. + """ + tokens = _voice_text_key(reply_text).split(" ") + if normalized_short in tokens: + return True + stem_len = max(len(normalized_short) - 2, 3) + stem = normalized_short[:stem_len] + return any(len(token) >= stem_len and token.startswith(stem) for token in tokens) + + +def _voice_greeting_word(language: str) -> str: + if str(language or "").strip().lower() == "kz": + return "Сәлеметсіз бе" + return "Здравствуйте" + + +def _voice_reply_with_name( + language: str, + reply_text: str, + name: str | None, + *, + greet: bool = False, +) -> str: + """Prefix `reply_text` with the customer's name. + + The prefix is always one of exactly two deterministic forms — "{name}, ..." + or "Здравствуйте, {name}, ..." (`greet=True`, used the turn their name is + first learned) — never left to the model's own free-form phrasing. That is + what keeps this from colliding with a self-introduced name inside + `reply_text` in the first place (see `_voice_reply_already_names_customer`). + """ short_name = _voice_short_name(name) if not short_name: return reply_text prefix = _voice_disclosure_prefix(language) normalized_short = _voice_text_key(short_name) + lead = f"{_voice_greeting_word(language)}, {short_name}" if greet else short_name if reply_text.startswith(prefix): rest = reply_text[len(prefix) :].lstrip() - if normalized_short in _voice_text_key(rest).split(" "): + if _voice_reply_already_names_customer(rest, normalized_short): return reply_text - return f"{prefix}{short_name}, {rest}" - if normalized_short in _voice_text_key(reply_text).split(" "): + return f"{prefix}{lead}, {rest}" + if _voice_reply_already_names_customer(reply_text, normalized_short): return reply_text - return f"{short_name}, {reply_text}" + return f"{lead}, {reply_text}" def _voice_name_metadata( @@ -1613,6 +1653,12 @@ def _voice_llm_prompt_messages( is_voice=True, config=operator_config, ) + system_prompt += ( + " Do not open `reply_text` with a greeting or by addressing the customer by name " + "(e.g. do not write 'Здравствуйте, <имя>' or start with '<имя>,'). The system inserts " + "the customer's name into the spoken reply separately, so naming them yourself would " + "make it get said twice." + ) if name_status in ("name_not_obtained", "name_followup_required"): system_prompt += ( " The user's name is not yet obtained. If the user explicitly provided their name in this turn, " @@ -2500,10 +2546,12 @@ def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDe decision_metadata.update(decision.get("metadata") or {}) suppress_name_prefix = bool(request_metadata.get("suppress_name_prefix")) if isinstance(request_metadata, dict) else False if effective_name_status == "name_obtained" and effective_name_value and not early_plan_only and not suppress_name_prefix: + just_learned_name = current_name_status != "name_obtained" decision["reply_text"] = _voice_reply_with_name( decision["language"], decision["reply_text"], effective_name_value, + greet=just_learned_name, ) elif inline_name_followup and not decision["needs_handoff"] and not early_plan_only: inline_followup = _voice_inline_name_followup(decision["language"], config) diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py index 399abdd..a48e666 100644 --- a/tests/test_ai_orchestrator_service.py +++ b/tests/test_ai_orchestrator_service.py @@ -1681,6 +1681,61 @@ def test_voice_llm_prompt_includes_context_summary_and_uses_12_segments(): assert payload["history"][0]["sequence_no"] == 4 +def test_voice_llm_prompt_instructs_model_not_to_self_name_customer(): + messages = voice_module._voice_llm_prompt_messages( + language="ru", + customer=None, + interaction=SimpleNamespace( + interaction_id="int_voice_prompt_name", + status="open", + queue_id="que_voice", + subject="schedule", + customer_id="cus_voice_prompt_name", + ), + transcript_text="Мне нужен график работы", + transcript_window=[], + conversation_summary_text="", + kb_results=[], + name_value="Ернур", + name_status="name_obtained", + ) + system_prompt = messages[0]["content"] + assert "addressing the customer by name" in system_prompt + + +def test_voice_reply_with_name_does_not_duplicate_inflected_name_form(): + # The model may address the customer using a grammatically declined form of + # their name ("Данияре" instead of "Данияр"); an exact-token dedup check + # would miss this and prepend the name a second time. + reply = voice_module._voice_reply_with_name( + "ru", "Здравствуйте, Данияре! Чем могу помочь?", "Данияр" + ) + assert reply == "Здравствуйте, Данияре! Чем могу помочь?" + + # A reply with no mention of the customer's name still gets it prefixed once. + reply = voice_module._voice_reply_with_name("ru", "Чем могу помочь?", "Данияр") + assert reply == "Данияр, Чем могу помочь?" + + +def test_voice_reply_with_name_greet_mode_uses_one_of_two_fixed_forms(): + # Regular turns (name already known): just the name, never a greeting word. + reply = voice_module._voice_reply_with_name("ru", "Чем могу помочь?", "Данияр", greet=False) + assert reply == "Данияр, Чем могу помочь?" + + # The turn the name is first learned: exactly "Здравствуйте, {name}, ...". + reply = voice_module._voice_reply_with_name("ru", "Чем могу помочь?", "Данияр", greet=True) + assert reply == "Здравствуйте, Данияр, Чем могу помочь?" + + reply = voice_module._voice_reply_with_name("kz", "Немен көмектесе аламын?", "Ерлан", greet=True) + assert reply == "Сәлеметсіз бе, Ерлан, Немен көмектесе аламын?" + + # Still deduplicates even in greet mode if the model already named the customer. + reply = voice_module._voice_reply_with_name( + "ru", "Здравствуйте, Данияре! Чем могу помочь?", "Данияр", greet=True + ) + assert reply == "Здравствуйте, Данияре! Чем могу помочь?" + + def test_voice_postprocess_reply_uses_summary_context_when_raw_window_lost_topic(): reply_text = voice_module._voice_postprocess_reply_text( language="ru",