diff --git a/api/core/dify_builder/models.py b/api/core/dify_builder/models.py index 77a32493ac6..4106e786287 100644 --- a/api/core/dify_builder/models.py +++ b/api/core/dify_builder/models.py @@ -243,6 +243,7 @@ class DifyBuilderContext: # -- Build fields (Slice 2, additive) -- goal_text: str = "" + reply_language: str = "" # detected user-input language (BCP-47); drives reply/localization requirements: dict[str, Any] = field(default_factory=dict) plan_items: list[str] = field(default_factory=list) plan_version_tag: str = "" diff --git a/api/core/dify_builder/runner.py b/api/core/dify_builder/runner.py index ec81643f977..ce4b0626b1a 100644 --- a/api/core/dify_builder/runner.py +++ b/api/core/dify_builder/runner.py @@ -107,6 +107,10 @@ class Env: # the current operation and persisted with its assistant turn, independent # from observable execution progress. emit_reasoning: Callable[[ReasoningEventData], None] | None = None + # Detects the user's input language (BCP-47) for a turn's user text. None -> no detection. + detect_language: Callable[[str], str] | None = None + # Localizes an about-to-be-committed item list into the given language. None -> no-op. + localize_items: Callable[[list["ConversationItem"], str], list["ConversationItem"]] | None = None # Correlation metadata for the handler transition currently running. # The runner resets it before every independently committed step. session_id: str = "" @@ -180,6 +184,8 @@ class Runner: settled: bool, ) -> None: at_version = session.version + 1 + if self._env.localize_items is not None and context.reply_language: + items = self._env.localize_items(items, context.reply_language) for item in items: # Handler helpers own conversation sequence numbers; the runner is # the only layer that knows which CAS version will make them @@ -251,6 +257,13 @@ class Runner: s, fc = self._env.repo.get_session(session_id) self._env.begin_operation(s) + if self._env.detect_language is not None: + user_text = turn.action.payload.get("text") if turn.action is not None else None + if isinstance(user_text, str) and user_text.strip(): + fc.reply_language = self._env.detect_language(user_text) + elif not fc.reply_language and fc.goal_text: + fc.reply_language = self._env.detect_language(fc.goal_text) + action_kind = turn.action.kind if turn.action is not None else "" # A retried message may arrive after its user bubble committed but the diff --git a/api/tests/unit_tests/core/dify_builder/test_reply_language_engine.py b/api/tests/unit_tests/core/dify_builder/test_reply_language_engine.py new file mode 100644 index 00000000000..ffead985e76 --- /dev/null +++ b/api/tests/unit_tests/core/dify_builder/test_reply_language_engine.py @@ -0,0 +1,65 @@ +"""Engine wiring: detect language from user input; localize items before commit.""" + +from datetime import datetime + +from core.dify_builder.models import Action, Actor, DifyBuilderContext, EntryMode, Session, Turn +from core.dify_builder.runner import Env, Runner +from core.dify_builder.state import PcState +from core.dify_builder.handlers_build import build_registry +from core.dify_builder.handlers_edit import edit_registry +from core.dify_builder.handlers_fix import fix_registry +from tests.unit_tests.core.dify_builder.fakes import FakeDifyPort, InMemoryRepository +from core.dify_builder.placeholder_agent import PlaceholderAgent + + +def _env(**overrides): + repo = InMemoryRepository() + env = Env(dify=FakeDifyPort(), agent=PlaceholderAgent(), repo=repo, now=lambda: datetime.min, **overrides) + return env, repo + + +def test_context_has_reply_language_default(): + assert DifyBuilderContext().reply_language == "" + + +def test_localize_items_called_on_commit_with_reply_language(): + seen = {} + + def localize(items, language): + seen["language"] = language + for it in items: + if it.payload.get("text") == "PING": + it.payload["text"] = "PONG" + return items + + env, repo = _env(localize_items=localize) + runner = Runner(env, fix_registry() | build_registry() | edit_registry()) + s = Session(app_id="app", tenant_id="t", owner_account_id="a", + entry_mode=EntryMode.FIX, current_state=PcState.FIX_AWAIT_DECISION) + fc = DifyBuilderContext(reply_language="zh-Hans") + repo.create_session(s, fc, []) + # a stop action produces a settled commit with no items -> localize still invoked + runner.advance(s.id, Turn(action=Action(kind="stop", base_version=1), actor=Actor(account_id="a", tenant_id="t"))) + assert seen.get("language") == "zh-Hans" + + +def test_detect_language_sets_reply_language_from_message_text(): + detected = [] + + def detect(text): + detected.append(text) + return "ja" + + env, repo = _env(detect_language=detect) + runner = Runner(env, fix_registry() | build_registry() | edit_registry()) + s = Session(app_id="app", tenant_id="t", owner_account_id="a", + entry_mode=EntryMode.FIX, current_state=PcState.FIX_AWAIT_DECISION) + repo.create_session(s, DifyBuilderContext(), []) + runner.advance( + s.id, + Turn(action=Action(kind="message", payload={"text": "テスト", "client_turn_id": "t1"}, base_version=1), + actor=Actor(account_id="a", tenant_id="t")), + ) + _, fc = repo.get_session(s.id) + assert fc.reply_language == "ja" + assert "テスト" in detected