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,225 @@
1
+ """Base classes and protocols for AI providers.
2
+
3
+ This module defines the abstract interface that all AI providers must implement,
4
+ ensuring consistent behavior across different translation backends.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class ProviderConfig:
17
+ """Configuration for an AI provider.
18
+
19
+ Attributes:
20
+ api_key: API key for authentication (None for local providers like Ollama)
21
+ model: Model name to use for translation
22
+ base_url: Optional base URL override for API endpoint
23
+ timeout: Request timeout in seconds
24
+ max_retries: Maximum number of retry attempts on failure
25
+ extra: Additional provider-specific configuration
26
+ """
27
+
28
+ api_key: str | None = None
29
+ model: str | None = None
30
+ base_url: str | None = None
31
+ timeout: float = 60.0
32
+ max_retries: int = 3
33
+ extra: dict[str, Any] = field(default_factory=dict)
34
+
35
+ @classmethod
36
+ def from_env(
37
+ cls,
38
+ env_var: str | None = None,
39
+ model: str | None = None,
40
+ **kwargs: Any,
41
+ ) -> "ProviderConfig":
42
+ """Create config from environment variable.
43
+
44
+ Args:
45
+ env_var: Environment variable name for API key
46
+ model: Model name to use
47
+ **kwargs: Additional configuration options
48
+
49
+ Returns:
50
+ ProviderConfig instance
51
+ """
52
+ api_key = os.getenv(env_var) if env_var else None
53
+ return cls(api_key=api_key, model=model, **kwargs)
54
+
55
+
56
+ @dataclass
57
+ class TranslationRequest:
58
+ """Request for translation.
59
+
60
+ Attributes:
61
+ text: Text to translate
62
+ source_lang: Source language code (e.g., 'en', 'ko')
63
+ target_lang: Target language code (e.g., 'ja', 'zh')
64
+ context: Optional context to improve translation quality
65
+ """
66
+
67
+ text: str
68
+ source_lang: str
69
+ target_lang: str
70
+ context: str | None = None
71
+
72
+
73
+ @dataclass
74
+ class TranslationResponse:
75
+ """Response from translation.
76
+
77
+ Attributes:
78
+ translated_text: The translated text
79
+ source_lang: Source language code
80
+ target_lang: Target language code
81
+ model: Model used for translation
82
+ provider: Provider name
83
+ usage: Token usage information (if available)
84
+ """
85
+
86
+ translated_text: str
87
+ source_lang: str
88
+ target_lang: str
89
+ model: str
90
+ provider: str
91
+ usage: dict[str, int] | None = None
92
+
93
+
94
+ class AIProvider(ABC):
95
+ """Abstract base class for AI translation providers.
96
+
97
+ All AI providers must inherit from this class and implement
98
+ the required abstract methods.
99
+
100
+ Example:
101
+ class MyProvider(AIProvider):
102
+ name = "my_provider"
103
+ env_var = "MY_API_KEY"
104
+ default_model = "my-model-v1"
105
+
106
+ async def translate(self, request: TranslationRequest) -> TranslationResponse:
107
+ # Implementation here
108
+ pass
109
+ """
110
+
111
+ # Class attributes to be overridden by subclasses
112
+ name: str = ""
113
+ display_name: str = ""
114
+ env_var: str | None = None
115
+ default_model: str = ""
116
+ supported_models: list[str] = []
117
+
118
+ def __init__(self, config: ProviderConfig | None = None) -> None:
119
+ """Initialize the provider.
120
+
121
+ Args:
122
+ config: Provider configuration. If None, will attempt to
123
+ create from environment variables.
124
+ """
125
+ if config is None:
126
+ config = ProviderConfig.from_env(
127
+ env_var=self.env_var,
128
+ model=self.default_model,
129
+ )
130
+ self.config = config
131
+ self._validate_config()
132
+
133
+ def _validate_config(self) -> None:
134
+ """Validate provider configuration.
135
+
136
+ Override this method in subclasses for provider-specific validation.
137
+
138
+ Raises:
139
+ APIKeyNotFoundError: If required API key is missing
140
+ """
141
+ from truthound_dashboard.translate.exceptions import APIKeyNotFoundError
142
+
143
+ if self.requires_api_key and not self.config.api_key:
144
+ raise APIKeyNotFoundError(self.name, self.env_var or "API_KEY")
145
+
146
+ @property
147
+ def requires_api_key(self) -> bool:
148
+ """Whether this provider requires an API key.
149
+
150
+ Override this in subclasses that don't require API keys (e.g., Ollama).
151
+ """
152
+ return True
153
+
154
+ @property
155
+ def model(self) -> str:
156
+ """Get the model to use for translation."""
157
+ return self.config.model or self.default_model
158
+
159
+ @abstractmethod
160
+ async def translate(self, request: TranslationRequest) -> TranslationResponse:
161
+ """Translate text using this provider.
162
+
163
+ Args:
164
+ request: Translation request containing text and language info
165
+
166
+ Returns:
167
+ TranslationResponse with translated text
168
+
169
+ Raises:
170
+ TranslationAPIError: If API call fails
171
+ """
172
+ pass
173
+
174
+ async def translate_batch(
175
+ self,
176
+ requests: list[TranslationRequest],
177
+ ) -> list[TranslationResponse]:
178
+ """Translate multiple texts.
179
+
180
+ Default implementation calls translate() for each request.
181
+ Override this in subclasses for more efficient batch processing.
182
+
183
+ Args:
184
+ requests: List of translation requests
185
+
186
+ Returns:
187
+ List of translation responses
188
+ """
189
+ import asyncio
190
+
191
+ return await asyncio.gather(*[self.translate(req) for req in requests])
192
+
193
+ @abstractmethod
194
+ async def is_available(self) -> bool:
195
+ """Check if the provider is available and configured.
196
+
197
+ Returns:
198
+ True if the provider can be used, False otherwise
199
+ """
200
+ pass
201
+
202
+ def get_translation_prompt(self, request: TranslationRequest) -> str:
203
+ """Generate the translation prompt.
204
+
205
+ Override this method to customize the prompt for specific providers.
206
+
207
+ Args:
208
+ request: Translation request
209
+
210
+ Returns:
211
+ Formatted prompt string
212
+ """
213
+ context_part = ""
214
+ if request.context:
215
+ context_part = f"\n\nContext: {request.context}"
216
+
217
+ return f"""Translate the following text from {request.source_lang} to {request.target_lang}.
218
+ Only output the translated text, nothing else.
219
+ Do not add any explanations, notes, or additional formatting.{context_part}
220
+
221
+ Text to translate:
222
+ {request.text}"""
223
+
224
+ def __repr__(self) -> str:
225
+ return f"{self.__class__.__name__}(model={self.model!r})"
@@ -0,0 +1,138 @@
1
+ """Mistral provider for translation.
2
+
3
+ This module implements the Mistral AI translation provider.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import httpx
9
+
10
+ from truthound_dashboard.translate.exceptions import TranslationAPIError
11
+ from truthound_dashboard.translate.providers.base import (
12
+ AIProvider,
13
+ ProviderConfig,
14
+ TranslationRequest,
15
+ TranslationResponse,
16
+ )
17
+
18
+
19
+ class MistralProvider(AIProvider):
20
+ """Mistral AI translation provider.
21
+
22
+ Uses Mistral AI's models for translation.
23
+
24
+ Environment:
25
+ MISTRAL_API_KEY: API key for authentication
26
+
27
+ Example:
28
+ provider = MistralProvider()
29
+ response = await provider.translate(
30
+ TranslationRequest(text="Hello", source_lang="en", target_lang="ja")
31
+ )
32
+ """
33
+
34
+ name = "mistral"
35
+ display_name = "Mistral AI"
36
+ env_var = "MISTRAL_API_KEY"
37
+ default_model = "mistral-small-latest"
38
+ supported_models = [
39
+ "mistral-large-latest",
40
+ "mistral-medium-latest",
41
+ "mistral-small-latest",
42
+ "open-mistral-7b",
43
+ "open-mixtral-8x7b",
44
+ "open-mixtral-8x22b",
45
+ ]
46
+
47
+ DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
48
+
49
+ def __init__(self, config: ProviderConfig | None = None) -> None:
50
+ super().__init__(config)
51
+ self.base_url = self.config.base_url or self.DEFAULT_BASE_URL
52
+
53
+ async def translate(self, request: TranslationRequest) -> TranslationResponse:
54
+ """Translate text using Mistral API.
55
+
56
+ Args:
57
+ request: Translation request
58
+
59
+ Returns:
60
+ Translation response with translated text
61
+
62
+ Raises:
63
+ TranslationAPIError: If API call fails
64
+ """
65
+ prompt = self.get_translation_prompt(request)
66
+
67
+ async with httpx.AsyncClient(timeout=self.config.timeout) as client:
68
+ try:
69
+ response = await client.post(
70
+ f"{self.base_url}/chat/completions",
71
+ headers={
72
+ "Authorization": f"Bearer {self.config.api_key}",
73
+ "Content-Type": "application/json",
74
+ },
75
+ json={
76
+ "model": self.model,
77
+ "messages": [
78
+ {
79
+ "role": "system",
80
+ "content": (
81
+ "You are a professional translator. "
82
+ "Translate the given text accurately and naturally. "
83
+ "Only output the translated text, nothing else."
84
+ ),
85
+ },
86
+ {"role": "user", "content": prompt},
87
+ ],
88
+ "temperature": 0.3,
89
+ "max_tokens": 4096,
90
+ },
91
+ )
92
+
93
+ if response.status_code != 200:
94
+ raise TranslationAPIError(
95
+ provider_name=self.name,
96
+ message=f"API request failed: {response.text}",
97
+ status_code=response.status_code,
98
+ response_body=response.text,
99
+ )
100
+
101
+ data = response.json()
102
+ translated_text = data["choices"][0]["message"]["content"].strip()
103
+
104
+ usage = None
105
+ if "usage" in data:
106
+ usage = {
107
+ "prompt_tokens": data["usage"].get("prompt_tokens", 0),
108
+ "completion_tokens": data["usage"].get("completion_tokens", 0),
109
+ "total_tokens": data["usage"].get("total_tokens", 0),
110
+ }
111
+
112
+ return TranslationResponse(
113
+ translated_text=translated_text,
114
+ source_lang=request.source_lang,
115
+ target_lang=request.target_lang,
116
+ model=self.model,
117
+ provider=self.name,
118
+ usage=usage,
119
+ )
120
+
121
+ except httpx.TimeoutException as e:
122
+ raise TranslationAPIError(
123
+ provider_name=self.name,
124
+ message=f"Request timed out after {self.config.timeout}s",
125
+ ) from e
126
+ except httpx.RequestError as e:
127
+ raise TranslationAPIError(
128
+ provider_name=self.name,
129
+ message=f"Request failed: {e}",
130
+ ) from e
131
+
132
+ async def is_available(self) -> bool:
133
+ """Check if Mistral API is available.
134
+
135
+ Returns:
136
+ True if API key is set
137
+ """
138
+ return bool(self.config.api_key)
@@ -0,0 +1,226 @@
1
+ """Ollama provider for translation.
2
+
3
+ This module implements the Ollama translation provider for local LLM
4
+ translation without requiring API keys.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+
11
+ from truthound_dashboard.translate.exceptions import (
12
+ OllamaNotRunningError,
13
+ TranslationAPIError,
14
+ )
15
+ from truthound_dashboard.translate.providers.base import (
16
+ AIProvider,
17
+ ProviderConfig,
18
+ TranslationRequest,
19
+ TranslationResponse,
20
+ )
21
+
22
+
23
+ class OllamaProvider(AIProvider):
24
+ """Ollama translation provider for local LLM.
25
+
26
+ Uses locally running Ollama models for translation.
27
+ No API key required.
28
+
29
+ Requirements:
30
+ - Ollama installed and running (ollama serve)
31
+ - A model pulled (e.g., ollama pull llama2)
32
+
33
+ Example:
34
+ provider = OllamaProvider()
35
+ response = await provider.translate(
36
+ TranslationRequest(text="Hello", source_lang="en", target_lang="ja")
37
+ )
38
+ """
39
+
40
+ name = "ollama"
41
+ display_name = "Ollama (Local)"
42
+ env_var = None # No API key needed
43
+ default_model = "llama3.2"
44
+ supported_models = [
45
+ "llama3.2",
46
+ "llama3.1",
47
+ "llama2",
48
+ "mistral",
49
+ "mixtral",
50
+ "qwen2.5",
51
+ "gemma2",
52
+ "phi3",
53
+ ]
54
+
55
+ DEFAULT_BASE_URL = "http://localhost:11434"
56
+
57
+ def __init__(self, config: ProviderConfig | None = None) -> None:
58
+ if config is None:
59
+ config = ProviderConfig(model=self.default_model)
60
+ super().__init__(config)
61
+ self.base_url = self.config.base_url or self.DEFAULT_BASE_URL
62
+
63
+ @property
64
+ def requires_api_key(self) -> bool:
65
+ """Ollama doesn't require an API key."""
66
+ return False
67
+
68
+ async def translate(self, request: TranslationRequest) -> TranslationResponse:
69
+ """Translate text using Ollama API.
70
+
71
+ Args:
72
+ request: Translation request
73
+
74
+ Returns:
75
+ Translation response with translated text
76
+
77
+ Raises:
78
+ OllamaNotRunningError: If Ollama is not running
79
+ TranslationAPIError: If API call fails
80
+ """
81
+ # First check if Ollama is running
82
+ if not await self.is_available():
83
+ raise OllamaNotRunningError()
84
+
85
+ prompt = self.get_translation_prompt(request)
86
+
87
+ async with httpx.AsyncClient(timeout=self.config.timeout) as client:
88
+ try:
89
+ response = await client.post(
90
+ f"{self.base_url}/api/generate",
91
+ json={
92
+ "model": self.model,
93
+ "prompt": prompt,
94
+ "stream": False,
95
+ "options": {
96
+ "temperature": 0.3,
97
+ "num_predict": 4096,
98
+ },
99
+ },
100
+ )
101
+
102
+ if response.status_code == 404:
103
+ raise TranslationAPIError(
104
+ provider_name=self.name,
105
+ message=(
106
+ f"Model '{self.model}' not found. "
107
+ f"Please pull it first: ollama pull {self.model}"
108
+ ),
109
+ status_code=404,
110
+ )
111
+
112
+ if response.status_code != 200:
113
+ raise TranslationAPIError(
114
+ provider_name=self.name,
115
+ message=f"API request failed: {response.text}",
116
+ status_code=response.status_code,
117
+ response_body=response.text,
118
+ )
119
+
120
+ data = response.json()
121
+ translated_text = data.get("response", "").strip()
122
+
123
+ # Clean up common artifacts from local models
124
+ translated_text = self._clean_response(translated_text)
125
+
126
+ return TranslationResponse(
127
+ translated_text=translated_text,
128
+ source_lang=request.source_lang,
129
+ target_lang=request.target_lang,
130
+ model=self.model,
131
+ provider=self.name,
132
+ usage=None, # Ollama doesn't provide token usage
133
+ )
134
+
135
+ except httpx.ConnectError as e:
136
+ raise OllamaNotRunningError() from e
137
+ except httpx.TimeoutException as e:
138
+ raise TranslationAPIError(
139
+ provider_name=self.name,
140
+ message=f"Request timed out after {self.config.timeout}s",
141
+ ) from e
142
+ except httpx.RequestError as e:
143
+ raise TranslationAPIError(
144
+ provider_name=self.name,
145
+ message=f"Request failed: {e}",
146
+ ) from e
147
+
148
+ def _clean_response(self, text: str) -> str:
149
+ """Clean up common artifacts from local model responses.
150
+
151
+ Args:
152
+ text: Raw response text
153
+
154
+ Returns:
155
+ Cleaned text
156
+ """
157
+ # Remove common prefixes that models might add
158
+ prefixes_to_remove = [
159
+ "Here is the translation:",
160
+ "Translation:",
161
+ "Translated text:",
162
+ "Here's the translation:",
163
+ ]
164
+ for prefix in prefixes_to_remove:
165
+ if text.lower().startswith(prefix.lower()):
166
+ text = text[len(prefix):].strip()
167
+
168
+ # Remove quotes if the entire response is quoted
169
+ if text.startswith('"') and text.endswith('"'):
170
+ text = text[1:-1]
171
+ if text.startswith("'") and text.endswith("'"):
172
+ text = text[1:-1]
173
+
174
+ return text.strip()
175
+
176
+ async def is_available(self) -> bool:
177
+ """Check if Ollama is running locally.
178
+
179
+ Returns:
180
+ True if Ollama server is responding
181
+ """
182
+ try:
183
+ async with httpx.AsyncClient(timeout=5.0) as client:
184
+ response = await client.get(f"{self.base_url}/api/tags")
185
+ return response.status_code == 200
186
+ except Exception:
187
+ return False
188
+
189
+ async def list_models(self) -> list[str]:
190
+ """List available models in Ollama.
191
+
192
+ Returns:
193
+ List of model names
194
+ """
195
+ try:
196
+ async with httpx.AsyncClient(timeout=10.0) as client:
197
+ response = await client.get(f"{self.base_url}/api/tags")
198
+ if response.status_code == 200:
199
+ data = response.json()
200
+ return [model["name"] for model in data.get("models", [])]
201
+ except Exception:
202
+ pass
203
+ return []
204
+
205
+ def get_translation_prompt(self, request: TranslationRequest) -> str:
206
+ """Generate a prompt optimized for local models.
207
+
208
+ Local models sometimes need more explicit instructions.
209
+
210
+ Args:
211
+ request: Translation request
212
+
213
+ Returns:
214
+ Formatted prompt string
215
+ """
216
+ context_part = ""
217
+ if request.context:
218
+ context_part = f"\nContext: {request.context}"
219
+
220
+ return f"""You are a professional translator. Translate the following text from {request.source_lang} to {request.target_lang}.
221
+
222
+ IMPORTANT: Only output the translated text. Do not include any explanations, notes, or the original text.{context_part}
223
+
224
+ Text: {request.text}
225
+
226
+ Translation:"""