mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
1 parent
0597a8b512
commit
28fc7d0fac
2 files changed
+52
-7
No files matched your search
@@ -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"] == "让我们明确一下需求。"
|
||||
Reference in new issue
Block a user