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.
- truthound_dashboard/cli.py +397 -0
- truthound_dashboard/translate/__init__.py +61 -0
- truthound_dashboard/translate/config_updater.py +327 -0
- truthound_dashboard/translate/exceptions.py +98 -0
- truthound_dashboard/translate/providers/__init__.py +49 -0
- truthound_dashboard/translate/providers/anthropic.py +135 -0
- truthound_dashboard/translate/providers/base.py +225 -0
- truthound_dashboard/translate/providers/mistral.py +138 -0
- truthound_dashboard/translate/providers/ollama.py +226 -0
- truthound_dashboard/translate/providers/openai.py +187 -0
- truthound_dashboard/translate/providers/registry.py +217 -0
- truthound_dashboard/translate/translator.py +443 -0
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/METADATA +103 -4
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/RECORD +17 -6
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.0.2.dist-info → truthound_dashboard-1.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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:"""
|