fix: persist detected conversation language + widget adopts it for the handoff form
Browse files- app/lang.py +20 -0
- app/orchestrator.py +14 -0
- app/static/videos/shopify-codigo.mp4 +1 -1
- app/static/videos/shopify-credenciales.mp4 +1 -1
- app/static/widget.js +13 -0
- appstore-assets/01-answers-24-7.png +1 -1
- appstore-assets/01-support.png +1 -1
- appstore-assets/02-languages.png +1 -1
- appstore-assets/02-multilingual.png +1 -1
- appstore-assets/03-products-orders.png +1 -1
- appstore-assets/03-sales.png +1 -1
- appstore-assets/04-setup.png +1 -1
- tests/test_lang.py +16 -0
- tests/test_orchestrator.py +19 -0
- tests/test_widget_assets.py +16 -0
app/lang.py
CHANGED
|
@@ -72,6 +72,26 @@ _NAMES = {
|
|
| 72 |
"zh-tw": "chino tradicional (Chinese)",
|
| 73 |
}
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
# langdetect codes where it confuses neighbours — disambiguated by markers.
|
| 76 |
_IBERIAN = ("es", "pt", "ca", "gl")
|
| 77 |
|
|
|
|
| 72 |
"zh-tw": "chino tradicional (Chinese)",
|
| 73 |
}
|
| 74 |
|
| 75 |
+
# Reverse of _NAMES: the pin-friendly display name detect_language() returns ->
|
| 76 |
+
# its compact ISO 639-1 code, for callers that want a wire-format code (e.g. the
|
| 77 |
+
# widget matching its own UI language to the conversation) rather than the
|
| 78 |
+
# display name meant for the LLM prompt. _NAMES has no duplicate values, so this
|
| 79 |
+
# is safe/exhaustive over every value detect_language() can return.
|
| 80 |
+
_ISO_BY_NAME = {v: k for k, v in _NAMES.items()}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def iso_code(language_name: str | None) -> str | None:
|
| 84 |
+
"""detect_language()'s display name ("English") -> its ISO code ("en"), or
|
| 85 |
+
None if not recognized. zh-cn/zh-tw both collapse to "zh" for a plain BCP-47
|
| 86 |
+
consumer (the widget's LANGS has no simplified/traditional split)."""
|
| 87 |
+
if not language_name:
|
| 88 |
+
return None
|
| 89 |
+
code = _ISO_BY_NAME.get(language_name)
|
| 90 |
+
if code in ("zh-cn", "zh-tw"):
|
| 91 |
+
return "zh"
|
| 92 |
+
return code
|
| 93 |
+
|
| 94 |
+
|
| 95 |
# langdetect codes where it confuses neighbours — disambiguated by markers.
|
| 96 |
_IBERIAN = ("es", "pt", "ca", "gl")
|
| 97 |
|
app/orchestrator.py
CHANGED
|
@@ -263,6 +263,20 @@ async def run_turn(
|
|
| 263 |
language = detect_language(m.content or "")
|
| 264 |
if language:
|
| 265 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
system_prompt = build_system_prompt(
|
| 267 |
brand_name, language, channel=ctx.channel, extra_instructions=instructions
|
| 268 |
)
|
|
|
|
| 263 |
language = detect_language(m.content or "")
|
| 264 |
if language:
|
| 265 |
break
|
| 266 |
+
# Persist the resolved language on the session so it can be RETURNED to the
|
| 267 |
+
# client (ChatResponse.lang below) and reflect what the bot is actually
|
| 268 |
+
# replying in. Was computed but never saved — every session's lang stayed
|
| 269 |
+
# None forever, so the widget had no real signal and fell back to the
|
| 270 |
+
# visitor's browser locale for everything, including the handoff contact
|
| 271 |
+
# form (a Spanish-locale visitor chatting in English saw an English reply
|
| 272 |
+
# but a Spanish "Tu nombre / Tu email" form — a real, observed drop-off).
|
| 273 |
+
# detect_language() returns a PIN-FRIENDLY DISPLAY name ("English"), not a
|
| 274 |
+
# wire code, so convert via iso_code() before storing/returning it.
|
| 275 |
+
from app.lang import iso_code
|
| 276 |
+
|
| 277 |
+
iso = iso_code(language)
|
| 278 |
+
if iso:
|
| 279 |
+
ctx.session.lang = iso
|
| 280 |
system_prompt = build_system_prompt(
|
| 281 |
brand_name, language, channel=ctx.channel, extra_instructions=instructions
|
| 282 |
)
|
app/static/videos/shopify-codigo.mp4
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 128
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0c42c1e18fb4eb8a204297dc7cf59ad0de9cae235a5159c747854ea316710c80
|
| 3 |
size 128
|
app/static/videos/shopify-credenciales.mp4
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 128
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5df6a98428fbde39ae22359e2a5983612be1ca490dd635021befae4fbe807974
|
| 3 |
size 128
|
app/static/widget.js
CHANGED
|
@@ -896,10 +896,23 @@
|
|
| 896 |
window.requestAnimationFrame(step);
|
| 897 |
}
|
| 898 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
// Single arrival point for a bot reply (streamed or classic): Phase 1
|
| 900 |
// notify() hook, typewriter text, then cards/handoff exactly as before.
|
| 901 |
function deliverReply(t, d) {
|
| 902 |
setSid(d.session_id);
|
|
|
|
| 903 |
t.classList.remove("ssb-typing"); t.classList.remove("ssb-stage");
|
| 904 |
notify();
|
| 905 |
typewrite(t, d.reply || "\u2026", function () {
|
|
|
|
| 896 |
window.requestAnimationFrame(step);
|
| 897 |
}
|
| 898 |
|
| 899 |
+
// The visitor's browser locale is only a GUESS for the very first (pre-
|
| 900 |
+
// conversation) render. Once the bot has actually detected the real
|
| 901 |
+
// conversation language, follow it \u2014 critically BEFORE the handoff contact
|
| 902 |
+
// form renders, so a visitor typing in English never sees a Spanish "Tu
|
| 903 |
+
// nombre / Tu email" form (a real, observed cause of drop-off: the bot
|
| 904 |
+
// replies correctly in their language, but the surrounding UI didn't match).
|
| 905 |
+
function adoptConversationLanguage(lang) {
|
| 906 |
+
if (!lang || lang === LANG || !LANGS[lang]) return;
|
| 907 |
+
LANG = lang; T = LANGS[LANG];
|
| 908 |
+
if (input) input.placeholder = T.input;
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
// Single arrival point for a bot reply (streamed or classic): Phase 1
|
| 912 |
// notify() hook, typewriter text, then cards/handoff exactly as before.
|
| 913 |
function deliverReply(t, d) {
|
| 914 |
setSid(d.session_id);
|
| 915 |
+
adoptConversationLanguage(d.lang);
|
| 916 |
t.classList.remove("ssb-typing"); t.classList.remove("ssb-stage");
|
| 917 |
notify();
|
| 918 |
typewrite(t, d.reply || "\u2026", function () {
|
appstore-assets/01-answers-24-7.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
appstore-assets/01-support.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
appstore-assets/02-languages.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
appstore-assets/02-multilingual.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
appstore-assets/03-products-orders.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
appstore-assets/03-sales.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
appstore-assets/04-setup.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
tests/test_lang.py
CHANGED
|
@@ -221,6 +221,22 @@ def test_prompt_pins_detected_language_else_mirrors():
|
|
| 221 |
assert "same language" in unsure and "mirror" in unsure
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
def test_prompt_forbids_redirecting_customer_to_third_parties():
|
| 225 |
"""Regression: the bot once told a Spanish customer to 'visit Toorx's website
|
| 226 |
/ contact their customer service' instead of helping. The prompt must forbid
|
|
|
|
| 221 |
assert "same language" in unsure and "mirror" in unsure
|
| 222 |
|
| 223 |
|
| 224 |
+
def test_iso_code_reverses_the_pin_friendly_display_name():
|
| 225 |
+
"""detect_language() returns a display name meant for the LLM prompt
|
| 226 |
+
("English", "español"), not a wire code -- iso_code() converts it back for
|
| 227 |
+
callers (the widget matching its own UI language to the conversation)."""
|
| 228 |
+
from app.lang import iso_code
|
| 229 |
+
|
| 230 |
+
assert iso_code("English") == "en"
|
| 231 |
+
assert iso_code("español") == "es"
|
| 232 |
+
assert iso_code("français") == "fr"
|
| 233 |
+
assert iso_code("chino simplificado (Chinese)") == "zh"
|
| 234 |
+
assert iso_code("chino tradicional (Chinese)") == "zh" # both zh variants collapse
|
| 235 |
+
assert iso_code("not a real language") is None
|
| 236 |
+
assert iso_code(None) is None
|
| 237 |
+
assert iso_code("") is None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
def test_prompt_forbids_redirecting_customer_to_third_parties():
|
| 241 |
"""Regression: the bot once told a Spanish customer to 'visit Toorx's website
|
| 242 |
/ contact their customer service' instead of helping. The prompt must forbid
|
tests/test_orchestrator.py
CHANGED
|
@@ -46,6 +46,25 @@ async def _session(db):
|
|
| 46 |
return s
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
async def test_info_turn_uses_knowledge_tool_then_answers(db_session):
|
| 50 |
router = FakeRouter(
|
| 51 |
[
|
|
|
|
| 46 |
return s
|
| 47 |
|
| 48 |
|
| 49 |
+
async def test_run_turn_persists_detected_language_on_session(db_session):
|
| 50 |
+
"""Regression: ctx.session.lang was computed for the reply but never SAVED,
|
| 51 |
+
so ChatResponse.lang (what the widget reads to match the handoff contact
|
| 52 |
+
form's language to the conversation) was always None -- a visitor chatting
|
| 53 |
+
in English could see a Spanish 'Tu nombre / Tu email' form."""
|
| 54 |
+
router = FakeRouter([
|
| 55 |
+
ChatResult(content="Sure, we ship for free.", tool_calls=[], finish_reason="stop"),
|
| 56 |
+
ChatResult(content="Sure, we ship for free.", tool_calls=[], finish_reason="stop"),
|
| 57 |
+
])
|
| 58 |
+
session = await _session(db_session)
|
| 59 |
+
assert session.lang is None
|
| 60 |
+
ctx = ToolContext(db=db_session, session=session)
|
| 61 |
+
|
| 62 |
+
resp = await run_turn(router, ctx, "Do you ship for free?", brand_name="Tienda")
|
| 63 |
+
|
| 64 |
+
assert resp.lang == "en"
|
| 65 |
+
assert session.lang == "en" # persisted, not just returned this once
|
| 66 |
+
|
| 67 |
+
|
| 68 |
async def test_info_turn_uses_knowledge_tool_then_answers(db_session):
|
| 69 |
router = FakeRouter(
|
| 70 |
[
|
tests/test_widget_assets.py
CHANGED
|
@@ -45,6 +45,22 @@ def test_widget_both_mode_can_switch_back_to_the_channel_picker():
|
|
| 45 |
assert js.count("switchChannel:") == 6, "switchChannel label must be defined in all 6 languages"
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def test_block_is_app_embed_loading_widget_in_proxy_mode():
|
| 49 |
liquid = BLOCK.read_text(encoding="utf-8")
|
| 50 |
assert "{% schema %}" in liquid
|
|
|
|
| 45 |
assert js.count("switchChannel:") == 6, "switchChannel label must be defined in all 6 languages"
|
| 46 |
|
| 47 |
|
| 48 |
+
def test_widget_adopts_the_real_conversation_language():
|
| 49 |
+
"""Regression (real leads found via the portal): the handoff contact form's
|
| 50 |
+
labels ('Tu nombre'/'Tu email') were pinned to navigator.language forever --
|
| 51 |
+
a visitor with a Spanish browser locale chatting in English still saw a
|
| 52 |
+
Spanish form. The widget must adopt the backend-detected d.lang (verified
|
| 53 |
+
live: 6/6 assertions in a real browser, form + input placeholder both
|
| 54 |
+
switch language mid-conversation) before the form renders."""
|
| 55 |
+
js = WIDGET.read_text(encoding="utf-8")
|
| 56 |
+
assert "function adoptConversationLanguage" in js
|
| 57 |
+
assert "adoptConversationLanguage(d.lang)" in js
|
| 58 |
+
# called BEFORE the handoff form check, in deliverReply's body
|
| 59 |
+
i_adopt = js.index("adoptConversationLanguage(d.lang)")
|
| 60 |
+
i_handoff = js.index("if (d.handoff) renderHandoffForm();")
|
| 61 |
+
assert i_adopt < i_handoff
|
| 62 |
+
|
| 63 |
+
|
| 64 |
def test_block_is_app_embed_loading_widget_in_proxy_mode():
|
| 65 |
liquid = BLOCK.read_text(encoding="utf-8")
|
| 66 |
assert "{% schema %}" in liquid
|