victor34593993 commited on
Commit
4831c30
·
verified ·
1 Parent(s): ed45229

fix: persist detected conversation language + widget adopts it for the handoff form

Browse files
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:4ca5fd0d908ded674df7eb0bc37a013f5db4d7db2858aba6072caa4e3f0fc5ed
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:9225e08b2d8c893c007fe2a1cf64e916ca15e15c06c09948e80f638aa0c152a2
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

  • SHA256: 8fcd536dafb63ab71dc6671f8bdc0e2f82d92453865568b38236a2dea9fc8095
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes

Git LFS Details

  • SHA256: 513170a485cdfcf55cc638bcfe7122d5bfdec56475266ce7c8d6dbdeaeb12f70
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes
appstore-assets/01-support.png CHANGED

Git LFS Details

  • SHA256: 2f48ea0a93fa1e23919f7e556fbbce49acef2f520484e567cc809d8973ff7b11
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes

Git LFS Details

  • SHA256: c1216de4202ec30dd1b2bcb02efd5285e7b369cdb096ec1cd263f0a64b82d2bb
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes
appstore-assets/02-languages.png CHANGED

Git LFS Details

  • SHA256: 6c1bc5857bcb23786205895e038cf83c404eaa33ee64df140d96cb70f5370844
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes

Git LFS Details

  • SHA256: 2624b52a35756adaa9e90df773c9dfd74a47e230116c0a7d966caa32ed1e5f78
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes
appstore-assets/02-multilingual.png CHANGED

Git LFS Details

  • SHA256: 9123637f0b09be3cde9d4f085a26deb9858615aad72419e366b03e0e87b381ef
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes

Git LFS Details

  • SHA256: 36eacdd89a8740aa75260577db3a99f6d1a94e9826ef3e95563bc1d1c0d9d972
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes
appstore-assets/03-products-orders.png CHANGED

Git LFS Details

  • SHA256: 6ccd82f23989c88ee2de46a27cee7f625a8cf7f235ca8435ff0e97d3f4b79ee8
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes

Git LFS Details

  • SHA256: 2c2c6ba584d42da8439cb04f82b3f7c57032b7c94cec26010e60d994aa84803c
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes
appstore-assets/03-sales.png CHANGED

Git LFS Details

  • SHA256: ca0c25c92feca891abd7b0d0826b578b5607d6663842ec99cb31a7c8d5ff1a93
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes

Git LFS Details

  • SHA256: 028c4c301ae9c67791e1edb736513cbc9a703292e9e95893d59101ce839dfffe
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes
appstore-assets/04-setup.png CHANGED

Git LFS Details

  • SHA256: f87f77448d2b1721d63fd4d44ec9a97d9223ed78875ec84ad5bc143685d41158
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes

Git LFS Details

  • SHA256: d9641a0782aec0127a49d6ac70311e05b413615ce73d7e75e8e3f05a4f093b6c
  • Pointer size: 128 Bytes
  • Size of remote file: 128 Bytes
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