feat(i18n): localize backend messages by workspace language

This commit is contained in:
Hyu
2026-10-15 05:31:04 +00:00
committed by RockChinQ
parent b1d9484876
commit e29fd27f02
74 changed files with 2296 additions and 177 deletions
+61
View File
@@ -928,3 +928,64 @@ async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspa
)
assert current.status_code == 404
assert (await current.get_json())['code'] == 'resource_not_found'
async def test_workspace_language_persists_and_controls_api_errors(workspace_api):
from langbot.pkg.i18n.workspace import get_workspace_language, set_workspace_language
application, client, engine, owner_token = workspace_api
current = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
current = (await current.get_json())['data']
workspace_uuid = current['workspace']['uuid']
owner_uuid = current['membership']['account_uuid']
headers = {**_auth(owner_token, workspace_uuid), 'X-LangBot-Language': 'ja-JP'}
initial = await client.get('/api/v1/workspaces/current/language', headers=headers)
assert (await initial.get_json())['data']['language'] == 'en_US'
updated = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': 'zh_Hans'})
assert updated.status_code == 200
assert await get_workspace_language(application, workspace_uuid) == 'zh_Hans'
restarted = SimpleNamespace(persistence_mgr=application.persistence_mgr)
assert await get_workspace_language(restarted, workspace_uuid) == 'zh_Hans'
invalid = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': '../bad'})
assert invalid.status_code == 400
assert await get_workspace_language(application, workspace_uuid, refresh=True) == 'zh_Hans'
# A second Workspace has independent persisted metadata and cache entries.
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.insert(Workspace).values(
uuid='language-other',
instance_uuid='instance-workspace-api',
name='Other',
slug='language-other',
source='cloud_projection',
type='team',
)
)
await set_workspace_language(application, 'language-other', 'es_ES')
assert await get_workspace_language(application, 'language-other') == 'es_ES'
assert await get_workspace_language(application, workspace_uuid) == 'zh_Hans'
# Read-only members can inspect the setting but cannot modify it. The
# verified Workspace setting wins over the browser language hint.
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.update(WorkspaceMembership)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
WorkspaceMembership.account_uuid == owner_uuid,
)
.values(role='viewer')
)
denied = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': 'ja_JP'})
assert denied.status_code == 403
assert (await denied.get_json())['msg'] == '没有执行此操作的权限'
visible = await client.get('/api/v1/workspaces/current/language', headers=headers)
assert visible.status_code == 200
assert (await visible.get_json())['data']['language'] == 'zh_Hans'
# An untrusted selector never causes us to read another tenant's language.
unauthorized = await client.get('/api/v1/workspaces/current/language', headers=_auth(owner_token, 'language-other'))
assert unauthorized.status_code in (403, 404)
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.controller import group
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
pytestmark = pytest.mark.asyncio
@@ -20,6 +21,8 @@ async def test_authenticated_route_does_not_hold_database_session_during_externa
release = asyncio.Event()
observations: list[bool] = []
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
async with engine.begin() as connection:
await connection.run_sync(WorkspaceMetadata.__table__.create)
persistence = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
persistence.db = SimpleNamespace(get_engine=lambda: engine)
@@ -60,7 +63,7 @@ async def test_authenticated_route_does_not_hold_database_session_during_externa
)
)
try:
await entered.wait()
await asyncio.wait_for(entered.wait(), timeout=5)
assert observations == [True]
release.set()
response = await request
@@ -15,6 +15,7 @@ from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvi
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import CodexCredential, EmbeddingModel, LLMModel, ModelProvider, RerankModel
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
@@ -51,7 +52,7 @@ async def deletion(tmp_path):
}
async with engine.begin() as conn:
for table in (User, Workspace, ModelProvider, CodexCredential, *MODEL_TYPES):
for table in (User, Workspace, WorkspaceMetadata, ModelProvider, CodexCredential, *MODEL_TYPES):
await conn.run_sync(table.__table__.create)
for workspace in contexts:
await conn.execute(
@@ -357,7 +357,7 @@ class TestUserServiceAuthenticate:
service = UserService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='用户不存在'):
with pytest.raises(ValueError, match='Resource not found'):
await service.authenticate('[email protected]', 'password')
async def test_authenticate_space_user_without_password_raises_error(self):
@@ -377,7 +377,7 @@ class TestUserServiceAuthenticate:
service = UserService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='请使用 LangBot 账号登录'):
with pytest.raises(ValueError, match='Please sign in with your LangBot Account'):
await service.authenticate('[email protected]', 'password')
@@ -418,7 +418,6 @@ class TestUserServiceGenerateJwtToken:
assert token is not None
class TestUserServiceVerifyJwtToken:
"""Tests for verify_jwt_token method."""
@@ -99,7 +99,11 @@ async def test_extensions_route_redacts_plugin_secrets_without_mutating_runtime_
@pytest.mark.asyncio
async def test_extensions_parallel_reads_open_explicit_child_task_scopes():
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
async with engine.begin() as connection:
await connection.run_sync(WorkspaceMetadata.__table__.create)
account = SimpleNamespace(uuid='account-a', user='[email protected]')
ap = SimpleNamespace()
ap.persistence_mgr = PersistenceManager(ap)
+12
View File
@@ -65,3 +65,15 @@ async def test_mcp_server_exposes_bot_event_route_tools():
assert 'test_bot_event_route' not in tool_names
assert 'list_processors' in tool_names
assert 'list_agents' not in tool_names
assert 'get_workspace_message_language' in tool_names
language_tool = next(tool for tool in tools if tool.name == 'set_workspace_message_language')
assert set(language_tool.inputSchema['properties']['language']['enum']) == {
'en_US',
'zh_Hans',
'zh_Hant',
'ja_JP',
'es_ES',
'ru_RU',
'th_TH',
'vi_VN',
}
+6
View File
@@ -88,6 +88,12 @@ class MockApplication:
def _create_mock_persistence_manager(self):
persistence_mgr = AsyncMock()
persistence_mgr.execute_async = AsyncMock()
result = Mock(scalar_one_or_none=Mock(return_value=None))
uow = AsyncMock()
uow.execute = AsyncMock(return_value=result)
boundary = AsyncMock()
boundary.__aenter__.return_value = uow
persistence_mgr.tenant_uow = Mock(return_value=boundary)
return persistence_mgr
def _create_mock_query_pool(self):
+2 -2
View File
@@ -82,7 +82,7 @@ async def test_invalid_sensitive_word_regex_still_blocks():
result = await filt.process(Mock(), 'any message')
assert result.level == entities.ResultLevel.BLOCK
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
assert result.user_notice == 'Content checking failed. Please contact the administrator.'
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
@@ -97,7 +97,7 @@ async def test_oversized_word_list_is_blocked():
assert result.level == entities.ResultLevel.BLOCK
assert result.replacement == ''
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
assert result.user_notice == 'Content checking failed. Please contact the administrator.'
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
@@ -881,9 +881,9 @@ async def test_lark_webhook_dispatches_native_form_submission():
response = await adapter.handle_unified_webhook('bot-1', '', request)
assert response['toast'] == {'type': 'success', 'content': 'Submitted / 已提交'}
assert response['toast'] == {'type': 'success', 'content': 'Submitted'}
assert response['card']['type'] == 'raw'
assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted / 已提交**'
assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted**'
assert len(calls) == 1
assert calls[0].action == 'interaction.submitted'
assert calls[0].data['callback_token'] == 'callback-token'
@@ -912,7 +912,7 @@ async def test_lark_webhook_cardkit_submission_uses_async_card_update_only():
response = await adapter.handle_unified_webhook('bot-1', '', request)
assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}}
assert response == {'toast': {'type': 'success', 'content': 'Submitted'}}
def test_lark_stream_card_separates_multiple_reasoning_rounds():
@@ -767,7 +767,7 @@ class TestHandleLiteLLMError:
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '请求参数错误' in str(exc_info.value)
assert 'Invalid request parameters' in str(exc_info.value)
def test_authentication_error(self):
"""Test AuthenticationError translation"""
@@ -778,7 +778,7 @@ class TestHandleLiteLLMError:
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert 'API key 无效' in str(exc_info.value)
assert 'Invalid API key' in str(exc_info.value)
def test_rate_limit_error(self):
"""Test RateLimitError translation"""
@@ -789,7 +789,7 @@ class TestHandleLiteLLMError:
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '请求过于频繁' in str(exc_info.value)
assert 'Rate limit exceeded' in str(exc_info.value)
def test_timeout_error(self):
"""Test Timeout translation"""
@@ -800,7 +800,7 @@ class TestHandleLiteLLMError:
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '请求超时' in str(exc_info.value)
assert 'Request timed out' in str(exc_info.value)
def test_context_window_error(self):
"""Test ContextWindowExceededError translation"""
@@ -811,7 +811,7 @@ class TestHandleLiteLLMError:
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(error)
assert '上下文长度超限' in str(exc_info.value)
assert 'Context length exceeded' in str(exc_info.value)
def test_unknown_error(self):
"""Test unknown error translation"""
@@ -820,7 +820,7 @@ class TestHandleLiteLLMError:
with pytest.raises(errors.RequesterError) as exc_info:
requester._handle_litellm_error(Exception('unknown'))
assert '未知错误' in str(exc_info.value)
assert 'Unknown error' in str(exc_info.value)
class TestInvokeLLM:
@@ -976,7 +976,7 @@ class TestInvokeLLM:
messages=messages,
)
assert 'API key 无效' in str(exc_info.value)
assert 'Invalid API key' in str(exc_info.value)
class TestInvokeEmbedding:
+107
View File
@@ -0,0 +1,107 @@
"""Runtime language isolation, catalog completeness, and Pipeline overrides."""
import asyncio
import json
import string
from pathlib import Path
from types import SimpleNamespace
import pytest
from langbot.pkg import i18n
from langbot.pkg.i18n.adapters import adapter_t, localized_adapter
from langbot.pkg.provider.modelmgr.errors import RequesterError
def test_catalogs_have_matching_keys_and_placeholders():
folder = Path(i18n.__file__).parent / 'locales'
catalogs = {
locale: json.loads((folder / f'{locale}.json').read_text(encoding='utf-8'))
for locale in i18n.SUPPORTED_LANGUAGES
}
english = catalogs['en_US']
formatter = string.Formatter()
def fields(text):
return {name for _, name, _, _ in formatter.parse(text) if name is not None}
for locale, catalog in catalogs.items():
assert catalog.keys() == english.keys(), locale
for key, text in catalog.items():
assert text.strip(), (locale, key)
assert fields(text) == fields(key), (locale, key)
@pytest.mark.asyncio
async def test_concurrent_requests_and_children_keep_their_language():
async def render(locale):
with i18n.language_scope(locale):
async def child():
await asyncio.sleep(0)
return str(RequesterError(i18n.t('Request timed out')))
return await asyncio.create_task(child())
assert await asyncio.gather(render('zh_Hans'), render('ja_JP'), render('en_US')) == [
'模型请求失败: 请求超时',
'モデルへのリクエストに失敗しました: リクエストがタイムアウトしました',
'Model request failed: Request timed out',
]
assert i18n.language.get() == 'en_US'
with pytest.raises(RuntimeError), i18n.language_scope('zh_Hans'):
raise RuntimeError('interrupted')
assert i18n.language.get() == 'en_US'
def test_pipeline_overrides_inherit_language_and_preserve_literal_braces():
query = SimpleNamespace(pipeline_config={'output': {'misc': {}}})
with i18n.language_scope('zh_Hans'):
assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == '请求失败。'
query.pipeline_config['output']['misc']['failure-hint'] = 'Request failed.'
assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == '请求失败。'
query.pipeline_config['output']['misc']['failure-hint'] = 'My custom notice {literal}'
assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == 'My custom notice {literal}'
query.pipeline_config['output']['misc']['tool-call-hint'] = 'Using {tools}; keep {other}'
assert i18n.pipeline_notice(query, 'tool-call-hint', 'Calling {tools}...', tools='search') == (
'Using search; keep {other}'
)
def test_diagnostics_are_never_parsed_or_rewritten():
diagnostic = 'upstream: {"error": "some provider detail"}'
with i18n.language_scope('zh_Hans'):
assert i18n.t(diagnostic) == diagnostic
assert i18n.error_text('permission_denied', 'Missing Workspace permission: workspace.update') == (
'没有执行此操作的权限'
)
assert i18n.t('Please do not replace model not found in user content') == (
'Please do not replace model not found in user content'
)
@pytest.mark.asyncio
async def test_adapter_callbacks_use_their_owner_not_the_callers_language():
app = SimpleNamespace(_message_language_cache={'bot-workspace': ('ja_JP', float('inf'))})
class Adapter:
logger = SimpleNamespace(ap=app, execution_context=SimpleNamespace(workspace_uuid='bot-workspace'))
@localized_adapter
async def reply(self):
return i18n.t('Submitted')
with i18n.language_scope('zh_Hans'):
assert await Adapter().reply() == '送信済み'
assert adapter_t(Adapter(), 'Submitted') == '送信済み'
assert i18n.language.get() == 'zh_Hans'
def test_lark_card_uses_selected_language():
from langbot.pkg.platform.adapters.lark.stream_card import build_card
with i18n.language_scope('ja_JP'):
card = json.dumps(build_card('<think>reason</think>answer'), ensure_ascii=False)
assert '思考過程' in card
assert 'AI による生成コンテンツ' in card
assert 'Thinking' not in card