mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-26 18:35:38 +08:00
feat(i18n): localize backend messages by workspace language
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user