truthound-dashboard 1.0.2__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,327 @@
1
+ """Intlayer configuration updater.
2
+
3
+ This module handles updating intlayer.config.ts and related frontend
4
+ configuration files when new languages are added.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from truthound_dashboard.translate.exceptions import ConfigUpdateError
14
+
15
+
16
+ @dataclass
17
+ class LocaleMapping:
18
+ """Mapping between language codes and Intlayer locale constants."""
19
+
20
+ code: str # ISO 639-1 code (e.g., 'ja', 'zh')
21
+ intlayer_const: str # Intlayer constant (e.g., 'JAPANESE', 'CHINESE')
22
+ name: str # English name
23
+ native_name: str # Native name
24
+ flag: str # Flag emoji
25
+
26
+ @classmethod
27
+ def from_code(cls, code: str) -> "LocaleMapping":
28
+ """Create a LocaleMapping from a language code.
29
+
30
+ Args:
31
+ code: ISO 639-1 language code
32
+
33
+ Returns:
34
+ LocaleMapping instance
35
+
36
+ Raises:
37
+ ValueError: If language code is not supported
38
+ """
39
+ code = code.lower()
40
+ if code not in LOCALE_MAPPINGS:
41
+ supported = ", ".join(sorted(LOCALE_MAPPINGS.keys()))
42
+ raise ValueError(
43
+ f"Unsupported language code: '{code}'. "
44
+ f"Supported codes: {supported}"
45
+ )
46
+ return LOCALE_MAPPINGS[code]
47
+
48
+
49
+ # Mapping of ISO 639-1 codes to Intlayer locale info
50
+ LOCALE_MAPPINGS: dict[str, LocaleMapping] = {
51
+ "en": LocaleMapping("en", "ENGLISH", "English", "English", "🇺🇸"),
52
+ "ko": LocaleMapping("ko", "KOREAN", "Korean", "한국어", "🇰🇷"),
53
+ "ja": LocaleMapping("ja", "JAPANESE", "Japanese", "日本語", "🇯🇵"),
54
+ "zh": LocaleMapping("zh", "CHINESE", "Chinese", "中文", "🇨🇳"),
55
+ "de": LocaleMapping("de", "GERMAN", "German", "Deutsch", "🇩🇪"),
56
+ "fr": LocaleMapping("fr", "FRENCH", "French", "Français", "🇫🇷"),
57
+ "es": LocaleMapping("es", "SPANISH", "Spanish", "Español", "🇪🇸"),
58
+ "pt": LocaleMapping("pt", "PORTUGUESE", "Portuguese", "Português", "🇵🇹"),
59
+ "it": LocaleMapping("it", "ITALIAN", "Italian", "Italiano", "🇮🇹"),
60
+ "ru": LocaleMapping("ru", "RUSSIAN", "Russian", "Русский", "🇷🇺"),
61
+ "ar": LocaleMapping("ar", "ARABIC", "Arabic", "العربية", "🇸🇦"),
62
+ "hi": LocaleMapping("hi", "HINDI", "Hindi", "हिन्दी", "🇮🇳"),
63
+ "th": LocaleMapping("th", "THAI", "Thai", "ไทย", "🇹🇭"),
64
+ "vi": LocaleMapping("vi", "VIETNAMESE", "Vietnamese", "Tiếng Việt", "🇻🇳"),
65
+ "nl": LocaleMapping("nl", "DUTCH", "Dutch", "Nederlands", "🇳🇱"),
66
+ "pl": LocaleMapping("pl", "POLISH", "Polish", "Polski", "🇵🇱"),
67
+ "tr": LocaleMapping("tr", "TURKISH", "Turkish", "Türkçe", "🇹🇷"),
68
+ "sv": LocaleMapping("sv", "SWEDISH", "Swedish", "Svenska", "🇸🇪"),
69
+ "da": LocaleMapping("da", "DANISH", "Danish", "Dansk", "🇩🇰"),
70
+ "no": LocaleMapping("no", "NORWEGIAN", "Norwegian", "Norsk", "🇳🇴"),
71
+ "fi": LocaleMapping("fi", "FINNISH", "Finnish", "Suomi", "🇫🇮"),
72
+ "cs": LocaleMapping("cs", "CZECH", "Czech", "Čeština", "🇨🇿"),
73
+ "hu": LocaleMapping("hu", "HUNGARIAN", "Hungarian", "Magyar", "🇭🇺"),
74
+ "el": LocaleMapping("el", "GREEK", "Greek", "Ελληνικά", "🇬🇷"),
75
+ "he": LocaleMapping("he", "HEBREW", "Hebrew", "עברית", "🇮🇱"),
76
+ "id": LocaleMapping("id", "INDONESIAN", "Indonesian", "Bahasa Indonesia", "🇮🇩"),
77
+ "ms": LocaleMapping("ms", "MALAY", "Malay", "Bahasa Melayu", "🇲🇾"),
78
+ "uk": LocaleMapping("uk", "UKRAINIAN", "Ukrainian", "Українська", "🇺🇦"),
79
+ "ro": LocaleMapping("ro", "ROMANIAN", "Romanian", "Română", "🇷🇴"),
80
+ "bg": LocaleMapping("bg", "BULGARIAN", "Bulgarian", "Български", "🇧🇬"),
81
+ "hr": LocaleMapping("hr", "CROATIAN", "Croatian", "Hrvatski", "🇭🇷"),
82
+ "sk": LocaleMapping("sk", "SLOVAK", "Slovak", "Slovenčina", "🇸🇰"),
83
+ "sl": LocaleMapping("sl", "SLOVENIAN", "Slovenian", "Slovenščina", "🇸🇮"),
84
+ "et": LocaleMapping("et", "ESTONIAN", "Estonian", "Eesti", "🇪🇪"),
85
+ "lv": LocaleMapping("lv", "LATVIAN", "Latvian", "Latviešu", "🇱🇻"),
86
+ "lt": LocaleMapping("lt", "LITHUANIAN", "Lithuanian", "Lietuvių", "🇱🇹"),
87
+ }
88
+
89
+
90
+ class IntlayerConfigUpdater:
91
+ """Updates Intlayer configuration files with new languages.
92
+
93
+ This class handles:
94
+ 1. intlayer.config.ts - Add new locales to the locales array
95
+ 2. providers/intlayer/config.ts - Add locale info for UI
96
+
97
+ Example:
98
+ updater = IntlayerConfigUpdater(frontend_dir)
99
+ updater.add_languages(["ja", "zh", "de"])
100
+ """
101
+
102
+ def __init__(self, frontend_dir: Path | str) -> None:
103
+ """Initialize the config updater.
104
+
105
+ Args:
106
+ frontend_dir: Path to the frontend directory
107
+ """
108
+ self.frontend_dir = Path(frontend_dir)
109
+ self.intlayer_config = self.frontend_dir / "intlayer.config.ts"
110
+ self.provider_config = (
111
+ self.frontend_dir / "src" / "providers" / "intlayer" / "config.ts"
112
+ )
113
+
114
+ def get_current_locales(self) -> list[str]:
115
+ """Get currently configured locales from intlayer.config.ts.
116
+
117
+ Returns:
118
+ List of language codes currently configured
119
+ """
120
+ if not self.intlayer_config.exists():
121
+ return []
122
+
123
+ content = self.intlayer_config.read_text()
124
+
125
+ # Parse locales array: [Locales.ENGLISH, Locales.KOREAN]
126
+ pattern = r"locales:\s*\[([\s\S]*?)\]"
127
+ match = re.search(pattern, content)
128
+ if not match:
129
+ return []
130
+
131
+ locales_str = match.group(1)
132
+ locales = []
133
+
134
+ for const_name in re.findall(r"Locales\.(\w+)", locales_str):
135
+ for code, mapping in LOCALE_MAPPINGS.items():
136
+ if mapping.intlayer_const == const_name:
137
+ locales.append(code)
138
+ break
139
+
140
+ return locales
141
+
142
+ def add_languages(self, language_codes: list[str]) -> list[str]:
143
+ """Add new languages to the configuration.
144
+
145
+ Args:
146
+ language_codes: List of ISO 639-1 language codes to add
147
+
148
+ Returns:
149
+ List of actually added language codes (excluding already existing)
150
+
151
+ Raises:
152
+ ConfigUpdateError: If configuration update fails
153
+ """
154
+ # Validate all language codes first
155
+ mappings = []
156
+ for code in language_codes:
157
+ try:
158
+ mappings.append(LocaleMapping.from_code(code))
159
+ except ValueError as e:
160
+ raise ConfigUpdateError(str(self.intlayer_config), str(e)) from e
161
+
162
+ # Get current locales
163
+ current_locales = self.get_current_locales()
164
+ new_codes = [m.code for m in mappings if m.code not in current_locales]
165
+
166
+ if not new_codes:
167
+ return []
168
+
169
+ # Update intlayer.config.ts
170
+ self._update_intlayer_config(mappings)
171
+
172
+ # Update provider config.ts
173
+ self._update_provider_config(mappings)
174
+
175
+ return new_codes
176
+
177
+ def _update_intlayer_config(self, mappings: list[LocaleMapping]) -> None:
178
+ """Update intlayer.config.ts with new locales.
179
+
180
+ Args:
181
+ mappings: List of LocaleMapping objects to add
182
+ """
183
+ if not self.intlayer_config.exists():
184
+ raise ConfigUpdateError(
185
+ str(self.intlayer_config),
186
+ "File does not exist",
187
+ )
188
+
189
+ content = self.intlayer_config.read_text()
190
+ current_locales = self.get_current_locales()
191
+
192
+ # Build new locales array
193
+ all_locales = current_locales.copy()
194
+ for mapping in mappings:
195
+ if mapping.code not in all_locales:
196
+ all_locales.append(mapping.code)
197
+
198
+ # Generate locales string
199
+ locales_entries = []
200
+ for code in all_locales:
201
+ mapping = LOCALE_MAPPINGS[code]
202
+ locales_entries.append(f"Locales.{mapping.intlayer_const}")
203
+
204
+ new_locales_str = ", ".join(locales_entries)
205
+
206
+ # Replace locales array
207
+ pattern = r"(locales:\s*\[)[\s\S]*?(\])"
208
+ replacement = f"\\g<1>{new_locales_str}\\g<2>"
209
+ new_content = re.sub(pattern, replacement, content)
210
+
211
+ self.intlayer_config.write_text(new_content)
212
+
213
+ def _update_provider_config(self, mappings: list[LocaleMapping]) -> None:
214
+ """Update providers/intlayer/config.ts with new locale info.
215
+
216
+ Args:
217
+ mappings: List of LocaleMapping objects to add
218
+ """
219
+ if not self.provider_config.exists():
220
+ raise ConfigUpdateError(
221
+ str(self.provider_config),
222
+ "File does not exist",
223
+ )
224
+
225
+ content = self.provider_config.read_text()
226
+ current_locales = self.get_current_locales()
227
+
228
+ # Update SUPPORTED_LOCALES array
229
+ all_locales = current_locales.copy()
230
+ for mapping in mappings:
231
+ if mapping.code not in all_locales:
232
+ all_locales.append(mapping.code)
233
+
234
+ # Generate SUPPORTED_LOCALES entries
235
+ locales_entries = []
236
+ for code in all_locales:
237
+ mapping = LOCALE_MAPPINGS[code]
238
+ locales_entries.append(f"Locales.{mapping.intlayer_const}")
239
+
240
+ new_supported_str = ", ".join(locales_entries)
241
+
242
+ # Replace SUPPORTED_LOCALES
243
+ pattern = r"(export const SUPPORTED_LOCALES = \[)[\s\S]*?(\] as const)"
244
+ replacement = f"\\g<1>{new_supported_str}\\g<2>"
245
+ new_content = re.sub(pattern, replacement, content)
246
+
247
+ # Update LOCALE_INFO array
248
+ locale_info_entries = []
249
+ for code in all_locales:
250
+ mapping = LOCALE_MAPPINGS[code]
251
+ entry = (
252
+ f" {{\n"
253
+ f" code: Locales.{mapping.intlayer_const},\n"
254
+ f" name: '{mapping.name}',\n"
255
+ f" nativeName: '{mapping.native_name}',\n"
256
+ f" flag: '{mapping.flag}',\n"
257
+ f" }}"
258
+ )
259
+ locale_info_entries.append(entry)
260
+
261
+ new_locale_info_str = ",\n".join(locale_info_entries)
262
+
263
+ # Replace LOCALE_INFO array
264
+ pattern = r"(export const LOCALE_INFO: readonly LocaleInfo\[\] = \[)\n[\s\S]*?(\n\] as const)"
265
+ replacement = f"\\g<1>\n{new_locale_info_str},\\g<2>"
266
+ new_content = re.sub(pattern, replacement, new_content)
267
+
268
+ # Update getBrowserLocale function
269
+ self._update_browser_locale_function(new_content, all_locales)
270
+
271
+ self.provider_config.write_text(new_content)
272
+
273
+ def _update_browser_locale_function(
274
+ self,
275
+ content: str,
276
+ all_locales: list[str],
277
+ ) -> str:
278
+ """Update getBrowserLocale function with new language mappings.
279
+
280
+ This is a simplified approach - for complex cases, consider
281
+ using a proper AST parser.
282
+
283
+ Args:
284
+ content: Current file content
285
+ all_locales: List of all locale codes
286
+
287
+ Returns:
288
+ Updated content
289
+ """
290
+ # For now, we keep the basic structure and just ensure
291
+ # the function exists. Full implementation would require
292
+ # AST manipulation for clean results.
293
+ return content
294
+
295
+ def validate_config(self) -> bool:
296
+ """Validate that configuration files exist and are valid.
297
+
298
+ Returns:
299
+ True if all configurations are valid
300
+ """
301
+ if not self.intlayer_config.exists():
302
+ return False
303
+ if not self.provider_config.exists():
304
+ return False
305
+
306
+ try:
307
+ self.get_current_locales()
308
+ return True
309
+ except Exception:
310
+ return False
311
+
312
+
313
+ def get_supported_languages() -> list[dict[str, str]]:
314
+ """Get list of all supported languages for translation.
315
+
316
+ Returns:
317
+ List of dicts with language info
318
+ """
319
+ return [
320
+ {
321
+ "code": mapping.code,
322
+ "name": mapping.name,
323
+ "native_name": mapping.native_name,
324
+ "flag": mapping.flag,
325
+ }
326
+ for mapping in LOCALE_MAPPINGS.values()
327
+ ]
@@ -0,0 +1,98 @@
1
+ """Custom exceptions for the translation module."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class TranslationError(Exception):
7
+ """Base exception for translation-related errors."""
8
+
9
+ pass
10
+
11
+
12
+ class ProviderNotFoundError(TranslationError):
13
+ """Raised when a requested AI provider is not found or not supported."""
14
+
15
+ def __init__(self, provider_name: str, available: list[str] | None = None) -> None:
16
+ self.provider_name = provider_name
17
+ self.available = available or []
18
+ message = f"Provider '{provider_name}' not found."
19
+ if self.available:
20
+ message += f" Available providers: {', '.join(self.available)}"
21
+ super().__init__(message)
22
+
23
+
24
+ class APIKeyNotFoundError(TranslationError):
25
+ """Raised when required API key is not set in environment."""
26
+
27
+ def __init__(self, provider_name: str, env_var: str) -> None:
28
+ self.provider_name = provider_name
29
+ self.env_var = env_var
30
+ message = (
31
+ f"API key not found for provider '{provider_name}'. "
32
+ f"Please set the {env_var} environment variable."
33
+ )
34
+ super().__init__(message)
35
+
36
+
37
+ class TranslationAPIError(TranslationError):
38
+ """Raised when AI provider API call fails."""
39
+
40
+ def __init__(
41
+ self,
42
+ provider_name: str,
43
+ message: str,
44
+ status_code: int | None = None,
45
+ response_body: str | None = None,
46
+ ) -> None:
47
+ self.provider_name = provider_name
48
+ self.status_code = status_code
49
+ self.response_body = response_body
50
+ full_message = f"API error from '{provider_name}': {message}"
51
+ if status_code:
52
+ full_message += f" (status: {status_code})"
53
+ super().__init__(full_message)
54
+
55
+
56
+ class ContentParseError(TranslationError):
57
+ """Raised when content file parsing fails."""
58
+
59
+ def __init__(self, file_path: str, reason: str) -> None:
60
+ self.file_path = file_path
61
+ self.reason = reason
62
+ message = f"Failed to parse content file '{file_path}': {reason}"
63
+ super().__init__(message)
64
+
65
+
66
+ class ConfigUpdateError(TranslationError):
67
+ """Raised when intlayer config update fails."""
68
+
69
+ def __init__(self, config_path: str, reason: str) -> None:
70
+ self.config_path = config_path
71
+ self.reason = reason
72
+ message = f"Failed to update config '{config_path}': {reason}"
73
+ super().__init__(message)
74
+
75
+
76
+ class NodejsNotFoundError(TranslationError):
77
+ """Raised when Node.js is not installed or version is too old."""
78
+
79
+ def __init__(self, required_version: str = "18.0.0") -> None:
80
+ self.required_version = required_version
81
+ message = (
82
+ f"Node.js {required_version}+ is required for translation. "
83
+ "Please install from https://nodejs.org/"
84
+ )
85
+ super().__init__(message)
86
+
87
+
88
+ class OllamaNotRunningError(TranslationError):
89
+ """Raised when Ollama is not running for local LLM translation."""
90
+
91
+ def __init__(self) -> None:
92
+ message = (
93
+ "Ollama is not running. Please start Ollama first:\n"
94
+ " 1. Install: https://ollama.ai/download\n"
95
+ " 2. Start: ollama serve\n"
96
+ " 3. Pull a model: ollama pull llama2"
97
+ )
98
+ super().__init__(message)
@@ -0,0 +1,49 @@
1
+ """AI provider abstraction layer for translation.
2
+
3
+ This module provides a unified interface for multiple AI providers,
4
+ enabling seamless switching between different translation backends.
5
+
6
+ Example:
7
+ from truthound_dashboard.translate.providers import get_provider, detect_provider
8
+
9
+ # Explicit provider selection
10
+ provider = get_provider("openai")
11
+
12
+ # Auto-detection based on environment
13
+ provider = detect_provider()
14
+
15
+ # Translate text
16
+ result = await provider.translate(
17
+ text="Hello, world!",
18
+ source_lang="en",
19
+ target_lang="ja",
20
+ )
21
+ """
22
+
23
+ from truthound_dashboard.translate.providers.base import (
24
+ AIProvider,
25
+ ProviderConfig,
26
+ TranslationRequest,
27
+ TranslationResponse,
28
+ )
29
+ from truthound_dashboard.translate.providers.registry import (
30
+ ProviderRegistry,
31
+ get_provider,
32
+ detect_provider,
33
+ list_available_providers,
34
+ register_provider,
35
+ )
36
+
37
+ __all__ = [
38
+ # Base classes
39
+ "AIProvider",
40
+ "ProviderConfig",
41
+ "TranslationRequest",
42
+ "TranslationResponse",
43
+ # Registry functions
44
+ "ProviderRegistry",
45
+ "get_provider",
46
+ "detect_provider",
47
+ "list_available_providers",
48
+ "register_provider",
49
+ ]
@@ -0,0 +1,135 @@
1
+ """Anthropic provider for translation.
2
+
3
+ This module implements the Anthropic translation provider using
4
+ Claude models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+
11
+ from truthound_dashboard.translate.exceptions import TranslationAPIError
12
+ from truthound_dashboard.translate.providers.base import (
13
+ AIProvider,
14
+ ProviderConfig,
15
+ TranslationRequest,
16
+ TranslationResponse,
17
+ )
18
+
19
+
20
+ class AnthropicProvider(AIProvider):
21
+ """Anthropic translation provider.
22
+
23
+ Uses Anthropic's Claude models for translation.
24
+
25
+ Environment:
26
+ ANTHROPIC_API_KEY: API key for authentication
27
+
28
+ Example:
29
+ provider = AnthropicProvider()
30
+ response = await provider.translate(
31
+ TranslationRequest(text="Hello", source_lang="en", target_lang="ja")
32
+ )
33
+ """
34
+
35
+ name = "anthropic"
36
+ display_name = "Anthropic"
37
+ env_var = "ANTHROPIC_API_KEY"
38
+ default_model = "claude-sonnet-4-20250514"
39
+ supported_models = [
40
+ "claude-sonnet-4-20250514",
41
+ "claude-opus-4-20250514",
42
+ "claude-3-5-sonnet-20241022",
43
+ "claude-3-5-haiku-20241022",
44
+ "claude-3-opus-20240229",
45
+ "claude-3-sonnet-20240229",
46
+ "claude-3-haiku-20240307",
47
+ ]
48
+
49
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
50
+ API_VERSION = "2023-06-01"
51
+
52
+ def __init__(self, config: ProviderConfig | None = None) -> None:
53
+ super().__init__(config)
54
+ self.base_url = self.config.base_url or self.DEFAULT_BASE_URL
55
+
56
+ async def translate(self, request: TranslationRequest) -> TranslationResponse:
57
+ """Translate text using Anthropic API.
58
+
59
+ Args:
60
+ request: Translation request
61
+
62
+ Returns:
63
+ Translation response with translated text
64
+
65
+ Raises:
66
+ TranslationAPIError: If API call fails
67
+ """
68
+ prompt = self.get_translation_prompt(request)
69
+
70
+ async with httpx.AsyncClient(timeout=self.config.timeout) as client:
71
+ try:
72
+ response = await client.post(
73
+ f"{self.base_url}/v1/messages",
74
+ headers={
75
+ "x-api-key": self.config.api_key,
76
+ "anthropic-version": self.API_VERSION,
77
+ "Content-Type": "application/json",
78
+ },
79
+ json={
80
+ "model": self.model,
81
+ "max_tokens": 4096,
82
+ "system": (
83
+ "You are a professional translator. "
84
+ "Translate the given text accurately and naturally. "
85
+ "Only output the translated text, nothing else."
86
+ ),
87
+ "messages": [{"role": "user", "content": prompt}],
88
+ },
89
+ )
90
+
91
+ if response.status_code != 200:
92
+ raise TranslationAPIError(
93
+ provider_name=self.name,
94
+ message=f"API request failed: {response.text}",
95
+ status_code=response.status_code,
96
+ response_body=response.text,
97
+ )
98
+
99
+ data = response.json()
100
+ translated_text = data["content"][0]["text"].strip()
101
+
102
+ usage = None
103
+ if "usage" in data:
104
+ usage = {
105
+ "input_tokens": data["usage"].get("input_tokens", 0),
106
+ "output_tokens": data["usage"].get("output_tokens", 0),
107
+ }
108
+
109
+ return TranslationResponse(
110
+ translated_text=translated_text,
111
+ source_lang=request.source_lang,
112
+ target_lang=request.target_lang,
113
+ model=self.model,
114
+ provider=self.name,
115
+ usage=usage,
116
+ )
117
+
118
+ except httpx.TimeoutException as e:
119
+ raise TranslationAPIError(
120
+ provider_name=self.name,
121
+ message=f"Request timed out after {self.config.timeout}s",
122
+ ) from e
123
+ except httpx.RequestError as e:
124
+ raise TranslationAPIError(
125
+ provider_name=self.name,
126
+ message=f"Request failed: {e}",
127
+ ) from e
128
+
129
+ async def is_available(self) -> bool:
130
+ """Check if Anthropic API is available.
131
+
132
+ Returns:
133
+ True if API key is set
134
+ """
135
+ return bool(self.config.api_key)