|
13 | 13 | # See the License for the specific language governing permissions and |
14 | 14 | # limitations under the License. |
15 | 15 |
|
16 | | -from unittest.mock import MagicMock |
| 16 | +from unittest.mock import MagicMock, patch |
17 | 17 |
|
18 | | -# conftest.py |
19 | 18 | import pytest |
20 | 19 |
|
21 | 20 | from nemoguardrails.library.content_safety.actions import ( |
| 21 | + DEFAULT_REFUSAL_MESSAGES, |
| 22 | + SUPPORTED_LANGUAGES, |
| 23 | + _detect_language, |
| 24 | + _get_refusal_message, |
22 | 25 | content_safety_check_input, |
23 | 26 | content_safety_check_output, |
24 | 27 | content_safety_check_output_mapping, |
| 28 | + detect_language, |
25 | 29 | ) |
26 | 30 | from tests.utils import FakeLLM |
27 | 31 |
|
| 32 | +try: |
| 33 | + import fast_langdetect # noqa |
| 34 | + |
| 35 | + HAS_FAST_LANGDETECT = True |
| 36 | +except ImportError: |
| 37 | + HAS_FAST_LANGDETECT = False |
| 38 | + |
| 39 | +requires_fast_langdetect = pytest.mark.skipif(not HAS_FAST_LANGDETECT, reason="fast-langdetect not installed") |
| 40 | + |
28 | 41 |
|
29 | 42 | @pytest.fixture |
30 | 43 | def fake_llm(): |
@@ -150,3 +163,148 @@ def test_content_safety_check_output_mapping_default(): |
150 | 163 | """Test content_safety_check_output_mapping defaults to allowed=False when key is missing.""" |
151 | 164 | result = {"policy_violations": []} |
152 | 165 | assert content_safety_check_output_mapping(result) is False |
| 166 | + |
| 167 | + |
| 168 | +@requires_fast_langdetect |
| 169 | +class TestDetectLanguage: |
| 170 | + @pytest.mark.parametrize( |
| 171 | + "text,expected_lang", |
| 172 | + [ |
| 173 | + ("Hello, how are you today?", "en"), |
| 174 | + ("Hola, ¿cómo estás hoy?", "es"), |
| 175 | + ("你好,你今天好吗?", "zh"), |
| 176 | + ("Guten Tag, wie geht es Ihnen?", "de"), |
| 177 | + ("Bonjour, comment allez-vous?", "fr"), |
| 178 | + ("こんにちは、お元気ですか?", "ja"), |
| 179 | + ], |
| 180 | + ids=["english", "spanish", "chinese", "german", "french", "japanese"], |
| 181 | + ) |
| 182 | + def test_detect_language(self, text, expected_lang): |
| 183 | + assert _detect_language(text) == expected_lang |
| 184 | + |
| 185 | + def test_detect_language_empty_string(self): |
| 186 | + result = _detect_language("") |
| 187 | + assert result is None or result == "en" |
| 188 | + |
| 189 | + def test_detect_language_import_error(self): |
| 190 | + with patch.dict("sys.modules", {"fast_langdetect": None}): |
| 191 | + import nemoguardrails.library.content_safety.actions as actions_module |
| 192 | + |
| 193 | + _original_detect_language = actions_module._detect_language |
| 194 | + |
| 195 | + def patched_detect_language(text): |
| 196 | + try: |
| 197 | + raise ImportError("No module named 'fast_langdetect'") |
| 198 | + except ImportError: |
| 199 | + return None |
| 200 | + |
| 201 | + with patch.object(actions_module, "_detect_language", patched_detect_language): |
| 202 | + result = actions_module._detect_language("Hello") |
| 203 | + assert result is None |
| 204 | + |
| 205 | + def test_detect_language_exception(self): |
| 206 | + with patch("fast_langdetect.detect", side_effect=Exception("Detection failed")): |
| 207 | + result = _detect_language("Hello") |
| 208 | + assert result is None |
| 209 | + |
| 210 | + |
| 211 | +class TestGetRefusalMessage: |
| 212 | + @pytest.mark.parametrize("lang", list(SUPPORTED_LANGUAGES)) |
| 213 | + def test_default_messages(self, lang): |
| 214 | + result = _get_refusal_message(lang, None) |
| 215 | + assert result == DEFAULT_REFUSAL_MESSAGES[lang] |
| 216 | + |
| 217 | + def test_custom_message_used_when_available(self): |
| 218 | + custom = {"en": "Custom refusal", "es": "Rechazo personalizado"} |
| 219 | + assert _get_refusal_message("en", custom) == "Custom refusal" |
| 220 | + assert _get_refusal_message("es", custom) == "Rechazo personalizado" |
| 221 | + |
| 222 | + def test_unsupported_lang_falls_back_to_english(self): |
| 223 | + assert _get_refusal_message("xyz", None) == DEFAULT_REFUSAL_MESSAGES["en"] |
| 224 | + assert _get_refusal_message("xyz", {"en": "Custom fallback"}) == "Custom fallback" |
| 225 | + |
| 226 | + def test_lang_not_in_custom_uses_default(self): |
| 227 | + custom = {"en": "Custom English"} |
| 228 | + assert _get_refusal_message("es", custom) == DEFAULT_REFUSAL_MESSAGES["es"] |
| 229 | + |
| 230 | + |
| 231 | +@requires_fast_langdetect |
| 232 | +class TestDetectLanguageAction: |
| 233 | + @pytest.mark.asyncio |
| 234 | + @pytest.mark.parametrize( |
| 235 | + "user_message,expected_lang", |
| 236 | + [ |
| 237 | + ("Hello, how are you?", "en"), |
| 238 | + ("Hola, ¿cómo estás?", "es"), |
| 239 | + ("你好", "zh"), |
| 240 | + ], |
| 241 | + ids=["english", "spanish", "chinese"], |
| 242 | + ) |
| 243 | + async def test_detect_language_action(self, user_message, expected_lang): |
| 244 | + context = {"user_message": user_message} |
| 245 | + result = await detect_language(context=context, config=None) |
| 246 | + assert result["language"] == expected_lang |
| 247 | + assert result["refusal_message"] == DEFAULT_REFUSAL_MESSAGES[expected_lang] |
| 248 | + |
| 249 | + @pytest.mark.asyncio |
| 250 | + @pytest.mark.parametrize( |
| 251 | + "context", |
| 252 | + [None, {"user_message": ""}], |
| 253 | + ids=["no_context", "empty_message"], |
| 254 | + ) |
| 255 | + async def test_detect_language_action_defaults_to_english(self, context): |
| 256 | + result = await detect_language(context=context, config=None) |
| 257 | + assert result["language"] == "en" |
| 258 | + assert result["refusal_message"] == DEFAULT_REFUSAL_MESSAGES["en"] |
| 259 | + |
| 260 | + @pytest.mark.asyncio |
| 261 | + async def test_detect_language_action_unsupported_language_falls_back_to_english(self): |
| 262 | + with patch( |
| 263 | + "nemoguardrails.library.content_safety.actions._detect_language", |
| 264 | + return_value="xyz", |
| 265 | + ): |
| 266 | + context = {"user_message": "some text"} |
| 267 | + result = await detect_language(context=context, config=None) |
| 268 | + assert result["language"] == "en" |
| 269 | + assert result["refusal_message"] == DEFAULT_REFUSAL_MESSAGES["en"] |
| 270 | + |
| 271 | + @pytest.mark.asyncio |
| 272 | + async def test_detect_language_action_with_config_custom_messages(self): |
| 273 | + mock_config = MagicMock() |
| 274 | + mock_config.rails.config.content_safety.multilingual.refusal_messages = { |
| 275 | + "en": "Custom: Cannot help", |
| 276 | + "es": "Personalizado: No puedo ayudar", |
| 277 | + } |
| 278 | + |
| 279 | + context = {"user_message": "Hello"} |
| 280 | + result = await detect_language(context=context, config=mock_config) |
| 281 | + assert result["language"] == "en" |
| 282 | + assert result["refusal_message"] == "Custom: Cannot help" |
| 283 | + |
| 284 | + @pytest.mark.asyncio |
| 285 | + async def test_detect_language_action_with_config_no_multilingual(self): |
| 286 | + mock_config = MagicMock() |
| 287 | + mock_config.rails.config.content_safety.multilingual = None |
| 288 | + |
| 289 | + context = {"user_message": "Hello"} |
| 290 | + result = await detect_language(context=context, config=mock_config) |
| 291 | + assert result["language"] == "en" |
| 292 | + assert result["refusal_message"] == DEFAULT_REFUSAL_MESSAGES["en"] |
| 293 | + |
| 294 | + |
| 295 | +class TestSupportedLanguagesAndDefaults: |
| 296 | + def test_supported_languages_count(self): |
| 297 | + assert len(SUPPORTED_LANGUAGES) == 9 |
| 298 | + |
| 299 | + def test_supported_languages_contents(self): |
| 300 | + expected = {"en", "es", "zh", "de", "fr", "hi", "ja", "ar", "th"} |
| 301 | + assert SUPPORTED_LANGUAGES == expected |
| 302 | + |
| 303 | + def test_default_refusal_messages_has_all_supported_languages(self): |
| 304 | + for lang in SUPPORTED_LANGUAGES: |
| 305 | + assert lang in DEFAULT_REFUSAL_MESSAGES |
| 306 | + |
| 307 | + def test_default_refusal_messages_are_non_empty(self): |
| 308 | + for _lang, message in DEFAULT_REFUSAL_MESSAGES.items(): |
| 309 | + assert message |
| 310 | + assert len(message) > 0 |
0 commit comments