feat: enhance voice reply logic to prevent duplicate name addressing and improve greeting handling
deploy / deploy (push) Successful in 32s
deploy / deploy (push) Successful in 32s
This commit is contained in:
@@ -603,20 +603,60 @@ def _voice_has_name_correction(text: str | None) -> bool:
|
|||||||
return any(marker in normalized for marker in correction_markers)
|
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)
|
short_name = _voice_short_name(name)
|
||||||
if not short_name:
|
if not short_name:
|
||||||
return reply_text
|
return reply_text
|
||||||
prefix = _voice_disclosure_prefix(language)
|
prefix = _voice_disclosure_prefix(language)
|
||||||
normalized_short = _voice_text_key(short_name)
|
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):
|
if reply_text.startswith(prefix):
|
||||||
rest = reply_text[len(prefix) :].lstrip()
|
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 reply_text
|
||||||
return f"{prefix}{short_name}, {rest}"
|
return f"{prefix}{lead}, {rest}"
|
||||||
if normalized_short in _voice_text_key(reply_text).split(" "):
|
if _voice_reply_already_names_customer(reply_text, normalized_short):
|
||||||
return reply_text
|
return reply_text
|
||||||
return f"{short_name}, {reply_text}"
|
return f"{lead}, {reply_text}"
|
||||||
|
|
||||||
|
|
||||||
def _voice_name_metadata(
|
def _voice_name_metadata(
|
||||||
@@ -1613,6 +1653,12 @@ def _voice_llm_prompt_messages(
|
|||||||
is_voice=True,
|
is_voice=True,
|
||||||
config=operator_config,
|
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"):
|
if name_status in ("name_not_obtained", "name_followup_required"):
|
||||||
system_prompt += (
|
system_prompt += (
|
||||||
" The user's name is not yet obtained. If the user explicitly provided their name in this turn, "
|
" 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 {})
|
decision_metadata.update(decision.get("metadata") or {})
|
||||||
suppress_name_prefix = bool(request_metadata.get("suppress_name_prefix")) if isinstance(request_metadata, dict) else False
|
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:
|
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["reply_text"] = _voice_reply_with_name(
|
||||||
decision["language"],
|
decision["language"],
|
||||||
decision["reply_text"],
|
decision["reply_text"],
|
||||||
effective_name_value,
|
effective_name_value,
|
||||||
|
greet=just_learned_name,
|
||||||
)
|
)
|
||||||
elif inline_name_followup and not decision["needs_handoff"] and not early_plan_only:
|
elif inline_name_followup and not decision["needs_handoff"] and not early_plan_only:
|
||||||
inline_followup = _voice_inline_name_followup(decision["language"], config)
|
inline_followup = _voice_inline_name_followup(decision["language"], config)
|
||||||
|
|||||||
@@ -1681,6 +1681,61 @@ def test_voice_llm_prompt_includes_context_summary_and_uses_12_segments():
|
|||||||
assert payload["history"][0]["sequence_no"] == 4
|
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():
|
def test_voice_postprocess_reply_uses_summary_context_when_raw_window_lost_topic():
|
||||||
reply_text = voice_module._voice_postprocess_reply_text(
|
reply_text = voice_module._voice_postprocess_reply_text(
|
||||||
language="ru",
|
language="ru",
|
||||||
|
|||||||
Reference in New Issue
Block a user