fix(dify-builder): unwrap the model's translation envelope in the Localizer

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 <noreply@anthropic.com>
This commit is contained in:
Charles YaoandClaude Opus 4.8 committed 2026-09-10 17:03:29 -07:00
1 parent 0597a8b512
commit 28fc7d0fac
2 files changed
+52 -7

No files matched your search

+22 -3
View File
@@ -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
@@ -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"] == "让我们明确一下需求。"