From 28fc7d0fac4dcbce936bf8869d42dbb4c4e8f318 Mon Sep 17 00:00:00 2001 From: Charles Yao Date: Thu, 10 Sep 2026 17:03:29 -0700 Subject: [PATCH] fix(dify-builder): unwrap the model's translation envelope in the Localizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live e2e revealed M2 never translated the static catalog strings: the model returns the batch-translate mapping wrapped under a "strings" key (echoing the request envelope) — {"strings": {src: translation}} — so _fill_cache's top-level table.get(src) missed every source and cached the English original. Unwrap one level when the sources aren't top-level keys, and tighten the prompt to ask for a flat object. The unit-test fake now mirrors the real wrapped shape so this can't regress (a flat fake hid the bug). Co-Authored-By: Claude Opus 4.8 --- api/services/dify_builder/agent/localize.py | 25 ++++++++++++-- .../services/dify_builder/test_localize.py | 34 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/api/services/dify_builder/agent/localize.py b/api/services/dify_builder/agent/localize.py index 654b2eab76f..5e61f48caa0 100644 --- a/api/services/dify_builder/agent/localize.py +++ b/api/services/dify_builder/agent/localize.py @@ -140,8 +140,9 @@ class Localizer: system = ( f"You are a translation engine. Translate each string into the language " f"with BCP-47 code {language}. Preserve any {{placeholder}} tokens EXACTLY. " - "Do not translate proper nouns or code. Reply with ONLY a JSON object mapping " - "each original string to its translation." + "Do not translate proper nouns or code. Reply with ONLY a flat JSON object whose " + "keys are the original strings VERBATIM and whose values are the translations. " + "Do not nest, wrap, or rename the keys." ) user = json.dumps({"strings": misses}, ensure_ascii=False) try: @@ -149,6 +150,24 @@ class Localizer: except Exception: logger.exception("dify_builder: batch translation failed; leaving strings in English") return + mapping = self._unwrap_translation_table(table, misses) for src in misses: - out = table.get(src) + out = mapping.get(src) _TRANSLATION_CACHE[(src, language)] = out if isinstance(out, str) and out else src + + @staticmethod + def _unwrap_translation_table(table: dict, misses: list[str]) -> dict: + """Return the dict that actually maps sources -> translations. + + The model sometimes wraps the mapping under a single key (e.g. echoing + the ``{"strings": ...}`` request envelope): ``{"strings": {src: tr}}``. + If none of the sources are top-level keys, descend one level into the + first nested dict that carries them.""" + if not isinstance(table, dict): + return {} + if any(s in table for s in misses): + return table + for value in table.values(): + if isinstance(value, dict) and any(s in value for s in misses): + return value + return table diff --git a/api/tests/unit_tests/services/dify_builder/test_localize.py b/api/tests/unit_tests/services/dify_builder/test_localize.py index 25371671278..f36d50023c2 100644 --- a/api/tests/unit_tests/services/dify_builder/test_localize.py +++ b/api/tests/unit_tests/services/dify_builder/test_localize.py @@ -36,10 +36,13 @@ def _localizer(monkeypatch, *, detect="zh-Hans", table=None): def fake_invoke_json(model, *, system, user, **kw): # noqa: ARG001 model.translate_calls += 1 - # translate every requested source string via the table (default: prefix) + # translate every requested source string via the table (default: prefix). + # Mirror the REAL model behavior observed in the live stack: it wraps the + # mapping under a "strings" key (echoing the request envelope). The + # Localizer must unwrap this; a flat fake would hide that bug. import json srcs = json.loads(user)["strings"] if user.strip().startswith("{") else [] - return {s: table.get(s, f"<{s}>") for s in srcs} + return {"strings": {s: table.get(s, f"<{s}>") for s in srcs}} monkeypatch.setattr(mod.llm, "invoke_text", fake_invoke_text) monkeypatch.setattr(mod.llm, "invoke_json", fake_invoke_json) @@ -117,7 +120,7 @@ def test_localize_shares_cache_across_instances(monkeypatch): assert fake2.translate_calls == 0 # second instance reused the module-level cache -def test_detect_language_provider_raising_falls_back_to_en(monkeypatch): +def test_detect_language_provider_raising_falls_back_to_en(): # Env callbacks must not raise (see localize.Localizer usage in the engine # Env); a model_provider that raises must degrade to the "en" fallback # instead of propagating out of detect_language. @@ -128,7 +131,7 @@ def test_detect_language_provider_raising_falls_back_to_en(monkeypatch): assert loc.detect_language("hello") == "en" -def test_fill_cache_provider_raising_leaves_strings_untranslated(monkeypatch): +def test_fill_cache_provider_raising_leaves_strings_untranslated(): # Same contract for the translate path: a raising model_provider must not # propagate out of localize_items -- it should degrade to a no-op (original # strings kept). @@ -139,3 +142,26 @@ def test_fill_cache_provider_raising_leaves_strings_untranslated(monkeypatch): items = [ConversationItem(kind="summary", payload={"title": "Review"})] out = loc.localize_items(items, "zh-Hans") assert out[0].payload["title"] == "Review" + + +def test_unwrap_translation_table(): + misses = ["Review", "Test run"] + flat = {"Review": "评审", "Test run": "测试运行"} + # flat mapping: returned as-is + assert Localizer._unwrap_translation_table(flat, misses) == flat + # wrapped envelope (real model behavior): descend one level + wrapped = {"strings": flat} + assert Localizer._unwrap_translation_table(wrapped, misses) == flat + # differently-named wrapper key still unwraps by content + assert Localizer._unwrap_translation_table({"translations": flat}, misses) == flat + # non-dict / unrecognizable: returns a dict with no matching keys (safe fallback to English upstream) + assert Localizer._unwrap_translation_table([], misses) == {} + + +def test_localize_translates_when_model_wraps_response(monkeypatch): + # End-to-end within the Localizer: the fake now mirrors the real model's + # {"strings": {...}} envelope, so this only passes if _fill_cache unwraps it. + loc, _ = _localizer(monkeypatch, table={"Let's clarify the requirements.": "让我们明确一下需求。"}) + items = [ConversationItem(kind="assistant_turn", payload={"reply_text": "Let's clarify the requirements."})] + out = loc.localize_items(items, "zh-Hans") + assert out[0].payload["reply_text"] == "让我们明确一下需求。"