mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
Harden the Localizer's batch translation against models that format JSON differently (the Builder model is user-changeable): - correlate by numeric INDEX instead of echoing source strings as keys, so a model that normalizes/truncates long keys no longer misses every lookup - accept the reply as an index-keyed object OR a positional array, optionally wrapped one level (_translations_by_index) - cache only real translations; a failed/partial reply is left UNCACHED so the next turn retries (self-heals) instead of sticking on the English original TDD: added parser shape-variation tests + a no-cache-failure/self-heal test; the fake now speaks the index protocol. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
207 lines
9.1 KiB
Python
207 lines
9.1 KiB
Python
"""M2 Localizer: detect language, translate only catalog/template strings, cache."""
|
|
|
|
import pytest
|
|
|
|
from core.dify_builder.models import ConversationItem
|
|
from services.dify_builder.agent import localize as localize_mod
|
|
from services.dify_builder.agent.localize import Localizer
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_translation_cache():
|
|
# _TRANSLATION_CACHE is module-level (shared across Localizer instances so a
|
|
# language's finite vocabulary is really translated once process-wide). Tests
|
|
# must not leak cache entries into each other, so clear it before every test --
|
|
# this keeps e.g. test_localize_caches_translations' translate_calls == 1
|
|
# assertion deterministic regardless of test order.
|
|
localize_mod._TRANSLATION_CACHE.clear()
|
|
yield
|
|
localize_mod._TRANSLATION_CACHE.clear()
|
|
|
|
|
|
class _FakeModel:
|
|
"""Records calls; returns canned detection / translation payloads."""
|
|
def __init__(self):
|
|
self.translate_calls = 0
|
|
|
|
# detect_language uses invoke_text; translate uses invoke_json — patched below.
|
|
|
|
|
|
def _localizer(monkeypatch, *, detect="zh-Hans", table=None):
|
|
table = table or {}
|
|
from services.dify_builder.agent import localize as mod
|
|
|
|
def fake_invoke_text(model, *, system, user, **kw): # noqa: ARG001
|
|
return detect
|
|
|
|
def fake_invoke_json(model, *, system, user, **kw): # noqa: ARG001
|
|
model.translate_calls += 1
|
|
# The request is an index->string object ({"0": src0, ...}); reply with an
|
|
# index-keyed object of translations, matching the hardened protocol.
|
|
import json
|
|
req = json.loads(user)
|
|
return {idx: table.get(src, f"<{src}>") for idx, src in req.items()}
|
|
|
|
monkeypatch.setattr(mod.llm, "invoke_text", fake_invoke_text)
|
|
monkeypatch.setattr(mod.llm, "invoke_json", fake_invoke_json)
|
|
fake = _FakeModel()
|
|
return Localizer(lambda: fake), fake
|
|
|
|
|
|
def test_detect_language(monkeypatch):
|
|
loc, _ = _localizer(monkeypatch, detect="ja")
|
|
assert loc.detect_language("こんにちは") == "ja"
|
|
|
|
|
|
def test_detect_language_english_fallback_on_empty(monkeypatch):
|
|
loc, _ = _localizer(monkeypatch)
|
|
assert loc.detect_language("") == "en"
|
|
|
|
|
|
def test_detect_language_clean_code_passes(monkeypatch):
|
|
loc, _ = _localizer(monkeypatch, detect="ja")
|
|
assert loc.detect_language("hello") == "ja"
|
|
|
|
|
|
def test_detect_language_rejects_non_code_reply(monkeypatch):
|
|
loc, _ = _localizer(monkeypatch, detect="The language is Japanese.")
|
|
assert loc.detect_language("hello") == "en"
|
|
|
|
|
|
def test_localize_translates_catalog_string(monkeypatch):
|
|
loc, _ = _localizer(monkeypatch, table={"Test run": "测试运行"})
|
|
items = [ConversationItem(kind="test_result", payload={"title": "Test run", "tone": "success"})]
|
|
out = loc.localize_items(items, "zh-Hans")
|
|
assert out[0].payload["title"] == "测试运行"
|
|
assert out[0].payload["tone"] == "success" # enum untouched
|
|
|
|
|
|
def test_localize_leaves_non_catalog_prose_untouched(monkeypatch):
|
|
loc, _ = _localizer(monkeypatch)
|
|
items = [ConversationItem(kind="assistant_turn", payload={"reply_text": "这是我为你生成的计划"})]
|
|
out = loc.localize_items(items, "zh-Hans")
|
|
assert out[0].payload["reply_text"] == "这是我为你生成的计划" # LLM prose, not in catalog
|
|
|
|
|
|
def test_localize_template_reinserts_dynamic_part(monkeypatch):
|
|
loc, _ = _localizer(monkeypatch, table={"Workflow built ({count} nodes)": "已构建工作流({count} 个节点)"})
|
|
items = [ConversationItem(kind="summary", payload={"items": ["Workflow built (3 nodes)"]})]
|
|
out = loc.localize_items(items, "zh-Hans")
|
|
assert out[0].payload["items"][0] == "已构建工作流(3 个节点)"
|
|
|
|
|
|
def test_localize_english_is_noop(monkeypatch):
|
|
loc, fake = _localizer(monkeypatch)
|
|
items = [ConversationItem(kind="test_result", payload={"title": "Test run"})]
|
|
out = loc.localize_items(items, "en")
|
|
assert out[0].payload["title"] == "Test run"
|
|
assert fake.translate_calls == 0
|
|
|
|
|
|
def test_localize_caches_translations(monkeypatch):
|
|
loc, fake = _localizer(monkeypatch, table={"Review": "评审"})
|
|
for _ in range(3):
|
|
loc.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
|
|
assert fake.translate_calls == 1 # translated once, cached thereafter
|
|
|
|
|
|
def test_localize_shares_cache_across_instances(monkeypatch):
|
|
# The catalog translation cache is module-level: a fresh Localizer per
|
|
# advance-task invocation must still hit the cache a prior instance filled.
|
|
loc1, fake1 = _localizer(monkeypatch, table={"Review": "评审"})
|
|
loc1.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
|
|
assert fake1.translate_calls == 1
|
|
|
|
loc2, fake2 = _localizer(monkeypatch, table={"Review": "评审"})
|
|
out = loc2.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
|
|
assert out[0].payload["title"] == "评审"
|
|
assert fake2.translate_calls == 0 # second instance reused the module-level cache
|
|
|
|
|
|
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.
|
|
def raising_provider():
|
|
raise RuntimeError("boom")
|
|
|
|
loc = Localizer(raising_provider)
|
|
assert loc.detect_language("hello") == "en"
|
|
|
|
|
|
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).
|
|
def raising_provider():
|
|
raise RuntimeError("boom")
|
|
|
|
loc = Localizer(raising_provider)
|
|
items = [ConversationItem(kind="summary", payload={"title": "Review"})]
|
|
out = loc.localize_items(items, "zh-Hans")
|
|
assert out[0].payload["title"] == "Review"
|
|
|
|
|
|
def test_translations_by_index_tolerates_model_shape_variation():
|
|
n = 2
|
|
# index-keyed object (the requested shape)
|
|
assert Localizer._translations_by_index({"0": "评审", "1": "测试运行"}, n) == {0: "评审", 1: "测试运行"}
|
|
# index object wrapped one level (model echoing an envelope key)
|
|
wrapped_idx = {"strings": {"0": "评审", "1": "测试运行"}}
|
|
assert Localizer._translations_by_index(wrapped_idx, n) == {0: "评审", 1: "测试运行"}
|
|
# positional array
|
|
assert Localizer._translations_by_index(["评审", "测试运行"], n) == {0: "评审", 1: "测试运行"}
|
|
# wrapped positional array (differently-named key)
|
|
assert Localizer._translations_by_index({"translations": ["评审", "测试运行"]}, n) == {0: "评审", 1: "测试运行"}
|
|
# partial reply: only the present index maps; the missing one is simply absent
|
|
assert Localizer._translations_by_index({"0": "评审"}, n) == {0: "评审"}
|
|
# unusable shapes -> empty (caller keeps English, uncached)
|
|
assert Localizer._translations_by_index("nope", n) == {}
|
|
assert Localizer._translations_by_index({"weird": 123}, n) == {}
|
|
|
|
|
|
def test_localize_translates_when_model_wraps_index_object(monkeypatch):
|
|
# End-to-end: model wraps an index-keyed map under a key; localize must still translate.
|
|
from services.dify_builder.agent import localize as mod
|
|
|
|
def fake_invoke_text(model, *, system, user, **kw): # noqa: ARG001
|
|
return "zh-Hans"
|
|
|
|
def fake_invoke_json(model, *, system, user, **kw): # noqa: ARG001
|
|
import json
|
|
req = json.loads(user)
|
|
return {"strings": {idx: ("评审" if src == "Review" else "<" + src + ">") for idx, src in req.items()}}
|
|
|
|
monkeypatch.setattr(mod.llm, "invoke_text", fake_invoke_text)
|
|
monkeypatch.setattr(mod.llm, "invoke_json", fake_invoke_json)
|
|
loc = Localizer(lambda: object())
|
|
items = [ConversationItem(kind="summary", payload={"title": "Review"})]
|
|
out = loc.localize_items(items, "zh-Hans")
|
|
assert out[0].payload["title"] == "评审"
|
|
|
|
|
|
def test_localize_does_not_cache_failures_and_retries(monkeypatch):
|
|
# A model that returns nothing usable must NOT poison the cache with the English
|
|
# original; a later turn re-invokes the model (self-heal) instead of sticking.
|
|
from services.dify_builder.agent import localize as mod
|
|
|
|
calls = {"n": 0}
|
|
|
|
def fake_invoke_text(model, *, system, user, **kw): # noqa: ARG001
|
|
return "zh-Hans"
|
|
|
|
def fake_invoke_json(model, *, system, user, **kw): # noqa: ARG001
|
|
calls["n"] += 1
|
|
return {} # unusable / no translations
|
|
|
|
monkeypatch.setattr(mod.llm, "invoke_text", fake_invoke_text)
|
|
monkeypatch.setattr(mod.llm, "invoke_json", fake_invoke_json)
|
|
loc = Localizer(lambda: object())
|
|
|
|
out1 = loc.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
|
|
assert out1[0].payload["title"] == "Review" # kept English (no translation available)
|
|
assert ("Review", "zh-Hans") not in mod._TRANSLATION_CACHE # failure NOT cached
|
|
|
|
loc.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
|
|
assert calls["n"] == 2 # re-invoked on the next turn (self-heal), not stuck on cached English
|