supervertaler 1.9.153__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.
Potentially problematic release.
This version of supervertaler might be problematic. Click here for more details.
- Supervertaler.py +47886 -0
- modules/__init__.py +10 -0
- modules/ai_actions.py +964 -0
- modules/ai_attachment_manager.py +343 -0
- modules/ai_file_viewer_dialog.py +210 -0
- modules/autofingers_engine.py +466 -0
- modules/cafetran_docx_handler.py +379 -0
- modules/config_manager.py +469 -0
- modules/database_manager.py +1878 -0
- modules/database_migrations.py +417 -0
- modules/dejavurtf_handler.py +779 -0
- modules/document_analyzer.py +427 -0
- modules/docx_handler.py +689 -0
- modules/encoding_repair.py +319 -0
- modules/encoding_repair_Qt.py +393 -0
- modules/encoding_repair_ui.py +481 -0
- modules/feature_manager.py +350 -0
- modules/figure_context_manager.py +340 -0
- modules/file_dialog_helper.py +148 -0
- modules/find_replace.py +164 -0
- modules/find_replace_qt.py +457 -0
- modules/glossary_manager.py +433 -0
- modules/image_extractor.py +188 -0
- modules/keyboard_shortcuts_widget.py +571 -0
- modules/llm_clients.py +1211 -0
- modules/llm_leaderboard.py +737 -0
- modules/llm_superbench_ui.py +1401 -0
- modules/local_llm_setup.py +1104 -0
- modules/model_update_dialog.py +381 -0
- modules/model_version_checker.py +373 -0
- modules/mqxliff_handler.py +638 -0
- modules/non_translatables_manager.py +743 -0
- modules/pdf_rescue_Qt.py +1822 -0
- modules/pdf_rescue_tkinter.py +909 -0
- modules/phrase_docx_handler.py +516 -0
- modules/project_home_panel.py +209 -0
- modules/prompt_assistant.py +357 -0
- modules/prompt_library.py +689 -0
- modules/prompt_library_migration.py +447 -0
- modules/quick_access_sidebar.py +282 -0
- modules/ribbon_widget.py +597 -0
- modules/sdlppx_handler.py +874 -0
- modules/setup_wizard.py +353 -0
- modules/shortcut_manager.py +932 -0
- modules/simple_segmenter.py +128 -0
- modules/spellcheck_manager.py +727 -0
- modules/statuses.py +207 -0
- modules/style_guide_manager.py +315 -0
- modules/superbench_ui.py +1319 -0
- modules/superbrowser.py +329 -0
- modules/supercleaner.py +600 -0
- modules/supercleaner_ui.py +444 -0
- modules/superdocs.py +19 -0
- modules/superdocs_viewer_qt.py +382 -0
- modules/superlookup.py +252 -0
- modules/tag_cleaner.py +260 -0
- modules/tag_manager.py +333 -0
- modules/term_extractor.py +270 -0
- modules/termbase_entry_editor.py +842 -0
- modules/termbase_import_export.py +488 -0
- modules/termbase_manager.py +1060 -0
- modules/termview_widget.py +1172 -0
- modules/theme_manager.py +499 -0
- modules/tm_editor_dialog.py +99 -0
- modules/tm_manager_qt.py +1280 -0
- modules/tm_metadata_manager.py +545 -0
- modules/tmx_editor.py +1461 -0
- modules/tmx_editor_qt.py +2784 -0
- modules/tmx_generator.py +284 -0
- modules/tracked_changes.py +900 -0
- modules/trados_docx_handler.py +430 -0
- modules/translation_memory.py +715 -0
- modules/translation_results_panel.py +2134 -0
- modules/translation_services.py +282 -0
- modules/unified_prompt_library.py +659 -0
- modules/unified_prompt_manager_qt.py +3951 -0
- modules/voice_commands.py +920 -0
- modules/voice_dictation.py +477 -0
- modules/voice_dictation_lite.py +249 -0
- supervertaler-1.9.153.dist-info/METADATA +896 -0
- supervertaler-1.9.153.dist-info/RECORD +85 -0
- supervertaler-1.9.153.dist-info/WHEEL +5 -0
- supervertaler-1.9.153.dist-info/entry_points.txt +2 -0
- supervertaler-1.9.153.dist-info/licenses/LICENSE +21 -0
- supervertaler-1.9.153.dist-info/top_level.txt +2 -0
modules/llm_clients.py
ADDED
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM Clients Module for Supervertaler
|
|
3
|
+
=====================================
|
|
4
|
+
|
|
5
|
+
Specialized independent module for interacting with various LLM providers.
|
|
6
|
+
Can be used standalone or imported by other applications.
|
|
7
|
+
|
|
8
|
+
Supported Providers:
|
|
9
|
+
- OpenAI (GPT-4, GPT-4o, GPT-5, o1, o3)
|
|
10
|
+
- Anthropic (Claude Sonnet 4.5, Haiku 4.5, Opus 4.1)
|
|
11
|
+
- Google (Gemini 2.5 Flash, 2.5 Pro, 3 Pro Preview)
|
|
12
|
+
|
|
13
|
+
Claude 4 Models (Released 2025):
|
|
14
|
+
- Sonnet 4.5: Best balance - flagship model for general translation ($3/$15 per MTok)
|
|
15
|
+
- Haiku 4.5: Fast & affordable - 2x speed, 1/3 cost of Sonnet ($1/$5 per MTok)
|
|
16
|
+
- Opus 4.1: Premium quality - complex legal/technical translation ($15/$75 per MTok)
|
|
17
|
+
|
|
18
|
+
Temperature Handling:
|
|
19
|
+
- Reasoning models (GPT-5, o1, o3): temperature parameter OMITTED (not supported)
|
|
20
|
+
- Standard models: temperature=0.3
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
from modules.llm_clients import LLMClient
|
|
24
|
+
|
|
25
|
+
# Use default (Sonnet 4.5)
|
|
26
|
+
client = LLMClient(api_key="your-key", provider="claude")
|
|
27
|
+
|
|
28
|
+
# Or specify model
|
|
29
|
+
client = LLMClient(api_key="your-key", provider="claude", model="claude-haiku-4-5-20251001")
|
|
30
|
+
|
|
31
|
+
response = client.translate("Hello world", source_lang="en", target_lang="nl")
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
from typing import Dict, Optional, Literal, List
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_api_keys() -> Dict[str, str]:
|
|
40
|
+
"""Load API keys from api_keys.txt file (supports both root and user_data_private locations)"""
|
|
41
|
+
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
42
|
+
|
|
43
|
+
# Try user_data_private first (dev mode), then fallback to root
|
|
44
|
+
possible_paths = [
|
|
45
|
+
os.path.join(script_dir, "user_data_private", "api_keys.txt"),
|
|
46
|
+
os.path.join(script_dir, "api_keys.txt")
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
api_keys_file = None
|
|
50
|
+
for path in possible_paths:
|
|
51
|
+
if os.path.exists(path):
|
|
52
|
+
api_keys_file = path
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
# If no file exists, create example file from template
|
|
56
|
+
if api_keys_file is None:
|
|
57
|
+
api_keys_file = possible_paths[1] # Default to root
|
|
58
|
+
example_file = os.path.join(script_dir, "api_keys.example.txt")
|
|
59
|
+
|
|
60
|
+
# Create api_keys.txt from example if it exists
|
|
61
|
+
if os.path.exists(example_file) and not os.path.exists(api_keys_file):
|
|
62
|
+
try:
|
|
63
|
+
import shutil
|
|
64
|
+
shutil.copy(example_file, api_keys_file)
|
|
65
|
+
print(f"Created {api_keys_file} from example template.")
|
|
66
|
+
print("Please edit this file and add your API keys.")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print(f"Could not create api_keys.txt: {e}")
|
|
69
|
+
|
|
70
|
+
api_keys = {
|
|
71
|
+
"google": "", # For Gemini (primary key name)
|
|
72
|
+
"gemini": "", # For Gemini (alias - synced with 'google')
|
|
73
|
+
"google_translate": "", # For Google Cloud Translation API
|
|
74
|
+
"claude": "",
|
|
75
|
+
"openai": "",
|
|
76
|
+
"deepl": "",
|
|
77
|
+
"mymemory": "",
|
|
78
|
+
"ollama_endpoint": "http://localhost:11434" # Local LLM endpoint (no key needed)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if os.path.exists(api_keys_file):
|
|
82
|
+
try:
|
|
83
|
+
with open(api_keys_file, 'r', encoding='utf-8') as f:
|
|
84
|
+
for line in f:
|
|
85
|
+
line = line.strip()
|
|
86
|
+
if line and not line.startswith('#') and '=' in line:
|
|
87
|
+
key, value = line.split('=', 1)
|
|
88
|
+
key = key.strip()
|
|
89
|
+
value = value.strip()
|
|
90
|
+
if key in api_keys:
|
|
91
|
+
api_keys[key] = value
|
|
92
|
+
# Also check for ollama_endpoint
|
|
93
|
+
elif key == 'ollama_endpoint' and value:
|
|
94
|
+
api_keys['ollama_endpoint'] = value
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"Error loading API keys: {e}")
|
|
97
|
+
|
|
98
|
+
# Sync 'google' and 'gemini' keys (they're aliases for the same API)
|
|
99
|
+
# If one is set and the other isn't, copy the value
|
|
100
|
+
if api_keys.get('google') and not api_keys.get('gemini'):
|
|
101
|
+
api_keys['gemini'] = api_keys['google']
|
|
102
|
+
elif api_keys.get('gemini') and not api_keys.get('google'):
|
|
103
|
+
api_keys['google'] = api_keys['gemini']
|
|
104
|
+
|
|
105
|
+
# Set environment variable for Ollama endpoint if configured
|
|
106
|
+
if api_keys.get('ollama_endpoint'):
|
|
107
|
+
os.environ['OLLAMA_ENDPOINT'] = api_keys['ollama_endpoint']
|
|
108
|
+
|
|
109
|
+
return api_keys
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class LLMConfig:
|
|
114
|
+
"""Configuration for LLM client"""
|
|
115
|
+
provider: Literal["openai", "claude", "gemini"]
|
|
116
|
+
model: str
|
|
117
|
+
api_key: str
|
|
118
|
+
temperature: Optional[float] = None # Auto-detected if None
|
|
119
|
+
max_tokens: int = 16384 # Increased from 4096 for batch translation (100 segments needs ~16K tokens)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class LLMClient:
|
|
123
|
+
"""Universal LLM client for translation tasks"""
|
|
124
|
+
|
|
125
|
+
# Default models for each provider
|
|
126
|
+
DEFAULT_MODELS = {
|
|
127
|
+
"openai": "gpt-4o",
|
|
128
|
+
"claude": "claude-sonnet-4-5-20250929", # Claude Sonnet 4.5 (Sept 2025)
|
|
129
|
+
"gemini": "gemini-2.5-flash", # Gemini 2.5 Flash (2025)
|
|
130
|
+
"ollama": "qwen2.5:7b" # Local LLM via Ollama - excellent multilingual quality
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Available Ollama models with descriptions (for UI display)
|
|
134
|
+
OLLAMA_MODELS = {
|
|
135
|
+
"qwen2.5:3b": {
|
|
136
|
+
"name": "Qwen 2.5 3B",
|
|
137
|
+
"description": "Fast & lightweight - good for simple translations",
|
|
138
|
+
"size_gb": 2.0,
|
|
139
|
+
"ram_required": 4,
|
|
140
|
+
"quality_stars": 3,
|
|
141
|
+
"strengths": ["Fast", "Low memory", "Multilingual"],
|
|
142
|
+
"use_case": "Quick drafts, simple text, low-end hardware"
|
|
143
|
+
},
|
|
144
|
+
"qwen2.5:7b": {
|
|
145
|
+
"name": "Qwen 2.5 7B",
|
|
146
|
+
"description": "Recommended - excellent multilingual quality",
|
|
147
|
+
"size_gb": 4.4,
|
|
148
|
+
"ram_required": 8,
|
|
149
|
+
"quality_stars": 4,
|
|
150
|
+
"strengths": ["Excellent multilingual", "Good quality", "Balanced speed"],
|
|
151
|
+
"use_case": "General translation, most European languages"
|
|
152
|
+
},
|
|
153
|
+
"llama3.2:3b": {
|
|
154
|
+
"name": "Llama 3.2 3B",
|
|
155
|
+
"description": "Meta's efficient model - good English",
|
|
156
|
+
"size_gb": 2.0,
|
|
157
|
+
"ram_required": 4,
|
|
158
|
+
"quality_stars": 3,
|
|
159
|
+
"strengths": ["Fast", "Good English", "Efficient"],
|
|
160
|
+
"use_case": "English-centric translations, quick drafts"
|
|
161
|
+
},
|
|
162
|
+
"mistral:7b": {
|
|
163
|
+
"name": "Mistral 7B",
|
|
164
|
+
"description": "Strong European language support",
|
|
165
|
+
"size_gb": 4.1,
|
|
166
|
+
"ram_required": 8,
|
|
167
|
+
"quality_stars": 4,
|
|
168
|
+
"strengths": ["European languages", "French", "Fast inference"],
|
|
169
|
+
"use_case": "French, German, Spanish translations"
|
|
170
|
+
},
|
|
171
|
+
"gemma2:9b": {
|
|
172
|
+
"name": "Gemma 2 9B",
|
|
173
|
+
"description": "Google's quality model - best for size",
|
|
174
|
+
"size_gb": 5.5,
|
|
175
|
+
"ram_required": 10,
|
|
176
|
+
"quality_stars": 5,
|
|
177
|
+
"strengths": ["High quality", "Good reasoning", "Multilingual"],
|
|
178
|
+
"use_case": "Quality-focused translation, technical content"
|
|
179
|
+
},
|
|
180
|
+
"qwen2.5:14b": {
|
|
181
|
+
"name": "Qwen 2.5 14B",
|
|
182
|
+
"description": "Premium quality - needs 16GB+ RAM",
|
|
183
|
+
"size_gb": 9.0,
|
|
184
|
+
"ram_required": 16,
|
|
185
|
+
"quality_stars": 5,
|
|
186
|
+
"strengths": ["Excellent quality", "Complex text", "Nuanced translation"],
|
|
187
|
+
"use_case": "Premium translations, complex documents, high-end hardware"
|
|
188
|
+
},
|
|
189
|
+
"llama3.1:8b": {
|
|
190
|
+
"name": "Llama 3.1 8B",
|
|
191
|
+
"description": "Meta's capable model - good all-rounder",
|
|
192
|
+
"size_gb": 4.7,
|
|
193
|
+
"ram_required": 8,
|
|
194
|
+
"quality_stars": 4,
|
|
195
|
+
"strengths": ["Versatile", "Good quality", "Well-tested"],
|
|
196
|
+
"use_case": "General purpose translation"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# Vision-capable models (support image inputs)
|
|
201
|
+
VISION_MODELS = {
|
|
202
|
+
"openai": [
|
|
203
|
+
"gpt-4-vision-preview",
|
|
204
|
+
"gpt-4-turbo",
|
|
205
|
+
"gpt-4-turbo-2024-04-09",
|
|
206
|
+
"gpt-4o",
|
|
207
|
+
"gpt-4o-mini",
|
|
208
|
+
"chatgpt-4o-latest"
|
|
209
|
+
],
|
|
210
|
+
"claude": [
|
|
211
|
+
"claude-3-opus-20240229",
|
|
212
|
+
"claude-3-sonnet-20240229",
|
|
213
|
+
"claude-3-haiku-20240307",
|
|
214
|
+
"claude-3-5-sonnet-20240620",
|
|
215
|
+
"claude-3-5-sonnet-20241022",
|
|
216
|
+
"claude-sonnet-4-5-20250929",
|
|
217
|
+
"claude-haiku-4-5-20251001",
|
|
218
|
+
"claude-opus-4-1-20250805"
|
|
219
|
+
],
|
|
220
|
+
"gemini": [
|
|
221
|
+
"gemini-pro-vision",
|
|
222
|
+
"gemini-1.5-pro",
|
|
223
|
+
"gemini-1.5-flash",
|
|
224
|
+
"gemini-2.0-flash",
|
|
225
|
+
"gemini-2.5-flash",
|
|
226
|
+
"gemini-2.5-flash-lite",
|
|
227
|
+
"gemini-2.5-pro",
|
|
228
|
+
"gemini-3-pro-preview"
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Available Claude 4 models with descriptions
|
|
233
|
+
CLAUDE_MODELS = {
|
|
234
|
+
"claude-sonnet-4-5-20250929": {
|
|
235
|
+
"name": "Claude Sonnet 4.5",
|
|
236
|
+
"description": "Best balance - Flagship model for general translation",
|
|
237
|
+
"released": "2025-09-29",
|
|
238
|
+
"strengths": ["General translation", "Multilingual", "Fast", "Cost-effective"],
|
|
239
|
+
"pricing": {"input": 3, "output": 15}, # USD per million tokens
|
|
240
|
+
"use_case": "Recommended for most translation tasks"
|
|
241
|
+
},
|
|
242
|
+
"claude-haiku-4-5-20251001": {
|
|
243
|
+
"name": "Claude Haiku 4.5",
|
|
244
|
+
"description": "Fast & affordable - 2x speed, 1/3 cost of Sonnet",
|
|
245
|
+
"released": "2025-10-01",
|
|
246
|
+
"strengths": ["High-volume translation", "Speed", "Budget-friendly", "Batch processing"],
|
|
247
|
+
"pricing": {"input": 1, "output": 5},
|
|
248
|
+
"use_case": "Best for large translation projects where speed and cost matter"
|
|
249
|
+
},
|
|
250
|
+
"claude-opus-4-1-20250805": {
|
|
251
|
+
"name": "Claude Opus 4.1",
|
|
252
|
+
"description": "Premium quality - Complex reasoning for nuanced translation",
|
|
253
|
+
"released": "2025-08-05",
|
|
254
|
+
"strengths": ["Legal translation", "Technical documents", "Complex reasoning", "Highest accuracy"],
|
|
255
|
+
"pricing": {"input": 15, "output": 75},
|
|
256
|
+
"use_case": "Best for specialized legal/technical translation requiring deep reasoning"
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Reasoning models that don't support temperature parameter (must be omitted)
|
|
261
|
+
REASONING_MODELS = ["gpt-5", "o1", "o3"]
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
def get_claude_model_info(cls, model_id: Optional[str] = None) -> Dict:
|
|
265
|
+
"""
|
|
266
|
+
Get information about available Claude models
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
model_id: Specific model ID to get info for, or None for all models
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Dict with model information
|
|
273
|
+
|
|
274
|
+
Example:
|
|
275
|
+
# Get all models
|
|
276
|
+
models = LLMClient.get_claude_model_info()
|
|
277
|
+
for model_id, info in models.items():
|
|
278
|
+
print(f"{info['name']}: {info['description']}")
|
|
279
|
+
|
|
280
|
+
# Get specific model
|
|
281
|
+
info = LLMClient.get_claude_model_info("claude-sonnet-4-5-20250929")
|
|
282
|
+
print(info['use_case'])
|
|
283
|
+
"""
|
|
284
|
+
if model_id:
|
|
285
|
+
return cls.CLAUDE_MODELS.get(model_id, {})
|
|
286
|
+
return cls.CLAUDE_MODELS
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def get_ollama_model_info(cls, model_id: Optional[str] = None) -> Dict:
|
|
290
|
+
"""
|
|
291
|
+
Get information about available Ollama models
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
model_id: Specific model ID to get info for, or None for all models
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Dict with model information
|
|
298
|
+
"""
|
|
299
|
+
if model_id:
|
|
300
|
+
return cls.OLLAMA_MODELS.get(model_id, {})
|
|
301
|
+
return cls.OLLAMA_MODELS
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def check_ollama_status(cls, endpoint: str = None) -> Dict:
|
|
305
|
+
"""
|
|
306
|
+
Check if Ollama is running and get available models
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
endpoint: Ollama API endpoint (default: http://localhost:11434)
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Dict with:
|
|
313
|
+
- running: bool - whether Ollama is running
|
|
314
|
+
- models: list - available model names
|
|
315
|
+
- error: str - error message if not running
|
|
316
|
+
"""
|
|
317
|
+
import requests
|
|
318
|
+
|
|
319
|
+
endpoint = endpoint or os.environ.get('OLLAMA_ENDPOINT', 'http://localhost:11434')
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
# Check if Ollama is running
|
|
323
|
+
response = requests.get(f"{endpoint}/api/tags", timeout=5)
|
|
324
|
+
if response.status_code == 200:
|
|
325
|
+
data = response.json()
|
|
326
|
+
models = [m['name'] for m in data.get('models', [])]
|
|
327
|
+
return {
|
|
328
|
+
'running': True,
|
|
329
|
+
'models': models,
|
|
330
|
+
'endpoint': endpoint,
|
|
331
|
+
'error': None
|
|
332
|
+
}
|
|
333
|
+
else:
|
|
334
|
+
return {
|
|
335
|
+
'running': False,
|
|
336
|
+
'models': [],
|
|
337
|
+
'endpoint': endpoint,
|
|
338
|
+
'error': f"Ollama returned status {response.status_code}"
|
|
339
|
+
}
|
|
340
|
+
except requests.exceptions.ConnectionError:
|
|
341
|
+
return {
|
|
342
|
+
'running': False,
|
|
343
|
+
'models': [],
|
|
344
|
+
'endpoint': endpoint,
|
|
345
|
+
'error': "Cannot connect to Ollama. Please ensure Ollama is installed and running."
|
|
346
|
+
}
|
|
347
|
+
except Exception as e:
|
|
348
|
+
return {
|
|
349
|
+
'running': False,
|
|
350
|
+
'models': [],
|
|
351
|
+
'endpoint': endpoint,
|
|
352
|
+
'error': str(e)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def model_supports_vision(cls, provider: str, model_name: str) -> bool:
|
|
357
|
+
"""
|
|
358
|
+
Check if a model supports vision (image) inputs
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
provider: Provider name ("openai", "claude", "gemini")
|
|
362
|
+
model_name: Model identifier
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
True if model supports vision, False otherwise
|
|
366
|
+
"""
|
|
367
|
+
provider = provider.lower()
|
|
368
|
+
vision_models = cls.VISION_MODELS.get(provider, [])
|
|
369
|
+
return model_name in vision_models
|
|
370
|
+
|
|
371
|
+
def __init__(self, api_key: str = None, provider: str = "openai", model: Optional[str] = None, max_tokens: int = 16384):
|
|
372
|
+
"""
|
|
373
|
+
Initialize LLM client
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
api_key: API key for the provider (not required for 'ollama')
|
|
377
|
+
provider: "openai", "claude", "gemini", or "ollama"
|
|
378
|
+
model: Model name (uses default if None)
|
|
379
|
+
max_tokens: Maximum tokens for responses (default: 16384)
|
|
380
|
+
"""
|
|
381
|
+
self.provider = provider.lower()
|
|
382
|
+
self.api_key = api_key
|
|
383
|
+
self.model = model or self.DEFAULT_MODELS.get(self.provider)
|
|
384
|
+
self.max_tokens = max_tokens
|
|
385
|
+
|
|
386
|
+
if not self.model:
|
|
387
|
+
raise ValueError(f"Unknown provider: {provider}")
|
|
388
|
+
|
|
389
|
+
# Validate API key for cloud providers (not needed for Ollama)
|
|
390
|
+
if self.provider != "ollama" and not self.api_key:
|
|
391
|
+
raise ValueError(f"API key required for provider: {provider}")
|
|
392
|
+
|
|
393
|
+
# Auto-detect temperature based on model
|
|
394
|
+
self.temperature = self._get_temperature()
|
|
395
|
+
|
|
396
|
+
def _clean_translation_response(self, translation: str, prompt: str) -> str:
|
|
397
|
+
"""
|
|
398
|
+
Clean translation response to remove any prompt remnants.
|
|
399
|
+
|
|
400
|
+
Sometimes LLMs translate the entire prompt instead of just the source text.
|
|
401
|
+
This method attempts to extract only the actual translation.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
translation: Raw translation response from LLM
|
|
405
|
+
prompt: Original prompt sent to LLM
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Cleaned translation text
|
|
409
|
+
"""
|
|
410
|
+
if not translation:
|
|
411
|
+
return translation
|
|
412
|
+
|
|
413
|
+
# First, try to find the delimiter we added ("**YOUR TRANSLATION**")
|
|
414
|
+
# Everything after this delimiter should be the actual translation
|
|
415
|
+
delimiter_markers = [
|
|
416
|
+
"**YOUR TRANSLATION (provide ONLY the translated text, no numbering or labels):**",
|
|
417
|
+
"**YOUR TRANSLATION**",
|
|
418
|
+
"**YOUR TRANSLATION (provide ONLY",
|
|
419
|
+
"**JOUW VERTALING**",
|
|
420
|
+
"**TRANSLATION**",
|
|
421
|
+
"**VERTALING**",
|
|
422
|
+
"Translation:",
|
|
423
|
+
"Vertaling:",
|
|
424
|
+
"YOUR TRANSLATION",
|
|
425
|
+
"JOUW VERTALING",
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
# Try to split on delimiter first (most reliable)
|
|
429
|
+
import re
|
|
430
|
+
for marker in delimiter_markers:
|
|
431
|
+
# Use word boundary or newline before marker for better matching
|
|
432
|
+
pattern = re.escape(marker)
|
|
433
|
+
# Try with newline before it
|
|
434
|
+
pattern_with_newline = r'\n\s*' + pattern
|
|
435
|
+
match = re.search(pattern_with_newline, translation, re.IGNORECASE | re.MULTILINE)
|
|
436
|
+
if not match:
|
|
437
|
+
# Try without newline requirement
|
|
438
|
+
match = re.search(pattern, translation, re.IGNORECASE)
|
|
439
|
+
|
|
440
|
+
if match:
|
|
441
|
+
result = translation[match.end():].strip()
|
|
442
|
+
# Clean up any leading/trailing newlines, colons, or whitespace
|
|
443
|
+
result = re.sub(r'^[::\s\n\r]+', '', result)
|
|
444
|
+
result = result.strip()
|
|
445
|
+
if result:
|
|
446
|
+
# Additional cleanup: remove any remaining prompt patterns
|
|
447
|
+
result = self._remove_prompt_patterns(result)
|
|
448
|
+
if result and len(result) < len(translation) * 0.9: # Must be significantly shorter
|
|
449
|
+
return result
|
|
450
|
+
|
|
451
|
+
# Common patterns that indicate the prompt was translated
|
|
452
|
+
# These are translations of common prompt phrases
|
|
453
|
+
prompt_patterns = [
|
|
454
|
+
# Dutch translations of prompt instructions
|
|
455
|
+
"Als een professionele",
|
|
456
|
+
"Als professionele",
|
|
457
|
+
"U bent een expert",
|
|
458
|
+
"Uw taak is om",
|
|
459
|
+
"Tijdens het vertaalproces",
|
|
460
|
+
"De output moet uitsluitend bestaan",
|
|
461
|
+
"Waarschuwingsinformatie:",
|
|
462
|
+
"⚠️ PROFESSIONELE VERTAALCONTEXT:",
|
|
463
|
+
"vertaler",
|
|
464
|
+
"handleidingen",
|
|
465
|
+
"regelgeving",
|
|
466
|
+
"naleving",
|
|
467
|
+
"medische apparaten",
|
|
468
|
+
"professionele doeleinden",
|
|
469
|
+
"medisch advies",
|
|
470
|
+
"volledige documentcontext",
|
|
471
|
+
"tekstsegmenten",
|
|
472
|
+
"CAT-tool tags",
|
|
473
|
+
"memoQ-tags",
|
|
474
|
+
"Trados Studio-tags",
|
|
475
|
+
"CafeTran-tags",
|
|
476
|
+
# English patterns (in case language is mixed)
|
|
477
|
+
"As a professional",
|
|
478
|
+
"You are an expert",
|
|
479
|
+
"Your task is to",
|
|
480
|
+
"During the translation process",
|
|
481
|
+
"The output must consist exclusively",
|
|
482
|
+
"⚠️ PROFESSIONAL TRANSLATION CONTEXT:",
|
|
483
|
+
"professional translation",
|
|
484
|
+
"technical manuals",
|
|
485
|
+
"regulatory compliance",
|
|
486
|
+
"medical devices",
|
|
487
|
+
"professional purposes",
|
|
488
|
+
"medical advice",
|
|
489
|
+
"full document context",
|
|
490
|
+
"text segments",
|
|
491
|
+
"CAT tool tags",
|
|
492
|
+
"memoQ tags",
|
|
493
|
+
"Trados Studio tags",
|
|
494
|
+
"CafeTran tags",
|
|
495
|
+
]
|
|
496
|
+
|
|
497
|
+
# Check if translation contains prompt patterns - if so, it's likely a translated prompt
|
|
498
|
+
translation_lower = translation.lower()
|
|
499
|
+
prompt_pattern_count = sum(1 for pattern in prompt_patterns if pattern.lower() in translation_lower)
|
|
500
|
+
|
|
501
|
+
# If translation is suspiciously long and contains many prompt patterns, it's likely a translated prompt
|
|
502
|
+
if len(translation) > 300 and prompt_pattern_count >= 3:
|
|
503
|
+
# Try to find where actual translation starts
|
|
504
|
+
# Look for the end of the last prompt-like sentence
|
|
505
|
+
lines = translation.split('\n')
|
|
506
|
+
cleaned_lines = []
|
|
507
|
+
found_actual_translation = False
|
|
508
|
+
|
|
509
|
+
for i, line in enumerate(lines):
|
|
510
|
+
line_stripped = line.strip()
|
|
511
|
+
if not line_stripped:
|
|
512
|
+
if found_actual_translation:
|
|
513
|
+
cleaned_lines.append(line)
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
# Check if this line looks like prompt instruction
|
|
517
|
+
is_prompt = any(pattern.lower() in line_stripped.lower() for pattern in prompt_patterns)
|
|
518
|
+
|
|
519
|
+
# Also check if it's a very long line (likely prompt instructions)
|
|
520
|
+
if len(line_stripped) > 200:
|
|
521
|
+
prompt_phrases = sum(1 for pattern in prompt_patterns if pattern.lower() in line_stripped.lower())
|
|
522
|
+
if prompt_phrases >= 2:
|
|
523
|
+
is_prompt = True
|
|
524
|
+
|
|
525
|
+
if is_prompt:
|
|
526
|
+
# Skip prompt lines
|
|
527
|
+
continue
|
|
528
|
+
else:
|
|
529
|
+
# This might be actual translation
|
|
530
|
+
found_actual_translation = True
|
|
531
|
+
cleaned_lines.append(line)
|
|
532
|
+
|
|
533
|
+
result = '\n'.join(cleaned_lines).strip()
|
|
534
|
+
if result and len(result) < len(translation) * 0.7: # Significantly shorter = likely cleaned correctly
|
|
535
|
+
return self._remove_prompt_patterns(result)
|
|
536
|
+
|
|
537
|
+
# Final cleanup: remove any remaining prompt patterns
|
|
538
|
+
cleaned = self._remove_prompt_patterns(translation)
|
|
539
|
+
|
|
540
|
+
# If cleaned version is much shorter, it was likely cleaned correctly
|
|
541
|
+
if cleaned != translation and len(cleaned) < len(translation) * 0.8:
|
|
542
|
+
return cleaned
|
|
543
|
+
|
|
544
|
+
return translation
|
|
545
|
+
|
|
546
|
+
def _remove_prompt_patterns(self, text: str) -> str:
|
|
547
|
+
"""Remove prompt-like patterns from text"""
|
|
548
|
+
prompt_patterns = [
|
|
549
|
+
"Als een professionele", "Als professionele", "U bent een expert",
|
|
550
|
+
"Uw taak is om", "Tijdens het vertaalproces", "De output moet",
|
|
551
|
+
"Waarschuwingsinformatie:", "⚠️ PROFESSIONELE", "vertaler",
|
|
552
|
+
"handleidingen", "regelgeving", "naleving", "medische apparaten",
|
|
553
|
+
"professionele doeleinden", "medisch advies", "volledige documentcontext",
|
|
554
|
+
"tekstsegmenten", "CAT-tool tags", "memoQ-tags", "Trados Studio-tags",
|
|
555
|
+
"CafeTran-tags", "As a professional", "You are an expert",
|
|
556
|
+
"Your task is to", "During the translation process",
|
|
557
|
+
"The output must consist exclusively", "⚠️ PROFESSIONAL",
|
|
558
|
+
"professional translation", "technical manuals", "regulatory compliance",
|
|
559
|
+
"medical devices", "professional purposes", "medical advice",
|
|
560
|
+
"full document context", "text segments", "CAT tool tags",
|
|
561
|
+
"memoQ tags", "Trados Studio tags", "CafeTran tags",
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
lines = text.split('\n')
|
|
565
|
+
cleaned_lines = []
|
|
566
|
+
|
|
567
|
+
for line in lines:
|
|
568
|
+
line_lower = line.lower()
|
|
569
|
+
# Skip lines that contain prompt patterns
|
|
570
|
+
has_prompt = any(pattern.lower() in line_lower for pattern in prompt_patterns)
|
|
571
|
+
# Also skip very long lines that might be prompt instructions
|
|
572
|
+
if not has_prompt and (len(line.strip()) < 300 or len(line.strip().split()) < 50):
|
|
573
|
+
cleaned_lines.append(line)
|
|
574
|
+
|
|
575
|
+
result = '\n'.join(cleaned_lines).strip()
|
|
576
|
+
return result if result else text
|
|
577
|
+
|
|
578
|
+
def _get_temperature(self) -> Optional[float]:
|
|
579
|
+
"""Determine optimal temperature for model (None means omit parameter)"""
|
|
580
|
+
model_lower = self.model.lower()
|
|
581
|
+
|
|
582
|
+
# Reasoning models don't support temperature parameter - return None to omit it
|
|
583
|
+
if any(reasoning in model_lower for reasoning in self.REASONING_MODELS):
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
# Standard models use 0.3 for consistency
|
|
587
|
+
return 0.3
|
|
588
|
+
|
|
589
|
+
def translate(
|
|
590
|
+
self,
|
|
591
|
+
text: str,
|
|
592
|
+
source_lang: str = "en",
|
|
593
|
+
target_lang: str = "nl",
|
|
594
|
+
context: Optional[str] = None,
|
|
595
|
+
custom_prompt: Optional[str] = None,
|
|
596
|
+
max_tokens: Optional[int] = None,
|
|
597
|
+
images: Optional[List] = None
|
|
598
|
+
) -> str:
|
|
599
|
+
"""
|
|
600
|
+
Translate text using configured LLM
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
text: Text to translate
|
|
604
|
+
source_lang: Source language code
|
|
605
|
+
target_lang: Target language code
|
|
606
|
+
context: Optional context for translation
|
|
607
|
+
custom_prompt: Optional custom prompt (overrides default simple prompt)
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
Translated text
|
|
611
|
+
"""
|
|
612
|
+
# Use custom prompt if provided, otherwise build simple prompt
|
|
613
|
+
if custom_prompt:
|
|
614
|
+
prompt = custom_prompt
|
|
615
|
+
else:
|
|
616
|
+
# Build prompt
|
|
617
|
+
prompt = f"Translate the following text from {source_lang} to {target_lang}:\n\n{text}"
|
|
618
|
+
|
|
619
|
+
if context:
|
|
620
|
+
prompt = f"Context: {context}\n\n{prompt}"
|
|
621
|
+
|
|
622
|
+
# Log warning if images provided but model doesn't support vision
|
|
623
|
+
if images and not self.model_supports_vision(self.provider, self.model):
|
|
624
|
+
print(f"⚠️ Warning: Model {self.model} doesn't support vision. Images will be ignored.")
|
|
625
|
+
images = None # Don't pass to API
|
|
626
|
+
|
|
627
|
+
# Call appropriate provider
|
|
628
|
+
if self.provider == "openai":
|
|
629
|
+
return self._call_openai(prompt, max_tokens=max_tokens, images=images)
|
|
630
|
+
elif self.provider == "claude":
|
|
631
|
+
return self._call_claude(prompt, max_tokens=max_tokens, images=images)
|
|
632
|
+
elif self.provider == "gemini":
|
|
633
|
+
return self._call_gemini(prompt, max_tokens=max_tokens, images=images)
|
|
634
|
+
elif self.provider == "ollama":
|
|
635
|
+
return self._call_ollama(prompt, max_tokens=max_tokens)
|
|
636
|
+
else:
|
|
637
|
+
raise ValueError(f"Unsupported provider: {self.provider}")
|
|
638
|
+
|
|
639
|
+
def _call_openai(self, prompt: str, max_tokens: Optional[int] = None, images: Optional[List] = None) -> str:
|
|
640
|
+
"""Call OpenAI API with GPT-5/o1/o3 reasoning model support and vision capability"""
|
|
641
|
+
print(f"🔵 _call_openai START: model={self.model}, prompt_len={len(prompt)}, max_tokens={max_tokens}, images={len(images) if images else 0}")
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
from openai import OpenAI
|
|
645
|
+
except ImportError:
|
|
646
|
+
raise ImportError(
|
|
647
|
+
"OpenAI library not installed. Install with: pip install openai"
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# Detect if this is a reasoning model (GPT-5, o1, o3)
|
|
651
|
+
model_lower = self.model.lower()
|
|
652
|
+
is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3"])
|
|
653
|
+
|
|
654
|
+
# Reasoning models need MUCH longer timeout (they can take 5-10 minutes for large prompts)
|
|
655
|
+
timeout_seconds = 600.0 if is_reasoning_model else 120.0 # 10 min vs 2 min
|
|
656
|
+
client = OpenAI(api_key=self.api_key, timeout=timeout_seconds)
|
|
657
|
+
print(f"🔵 OpenAI client created successfully (timeout: {timeout_seconds}s)")
|
|
658
|
+
|
|
659
|
+
# Use provided max_tokens or default
|
|
660
|
+
# IMPORTANT: Reasoning models need MUCH higher limits because they use tokens for:
|
|
661
|
+
# 1. Internal reasoning/thinking (can be thousands of tokens)
|
|
662
|
+
# 2. The actual response content
|
|
663
|
+
# If limit is too low, all tokens get used for reasoning and response is empty!
|
|
664
|
+
if max_tokens is not None:
|
|
665
|
+
tokens_to_use = max_tokens
|
|
666
|
+
elif is_reasoning_model:
|
|
667
|
+
# For reasoning models, use 32K tokens (GPT-5 supports up to 65K)
|
|
668
|
+
# This gives plenty of room for both reasoning and response
|
|
669
|
+
tokens_to_use = 32768
|
|
670
|
+
else:
|
|
671
|
+
tokens_to_use = self.max_tokens
|
|
672
|
+
|
|
673
|
+
print(f"🔵 Is reasoning model: {is_reasoning_model}, tokens_to_use: {tokens_to_use}")
|
|
674
|
+
|
|
675
|
+
# Build message content (text + optional images)
|
|
676
|
+
if images:
|
|
677
|
+
# Vision API format: content as array with text and image_url objects
|
|
678
|
+
content = [{"type": "text", "text": prompt}]
|
|
679
|
+
for img_ref, img_base64 in images:
|
|
680
|
+
content.append({
|
|
681
|
+
"type": "image_url",
|
|
682
|
+
"image_url": {"url": f"data:image/png;base64,{img_base64}"}
|
|
683
|
+
})
|
|
684
|
+
print(f"🔵 Vision mode: {len(images)} images added to message")
|
|
685
|
+
else:
|
|
686
|
+
# Standard text-only format
|
|
687
|
+
content = prompt
|
|
688
|
+
|
|
689
|
+
# Build API call parameters
|
|
690
|
+
api_params = {
|
|
691
|
+
"model": self.model,
|
|
692
|
+
"messages": [{"role": "user", "content": content}],
|
|
693
|
+
"timeout": timeout_seconds
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if is_reasoning_model:
|
|
697
|
+
# Reasoning models (gpt-5, o1, o3-mini) require specific parameters
|
|
698
|
+
# - Use max_completion_tokens instead of max_tokens
|
|
699
|
+
# - DO NOT include temperature parameter (it's not supported)
|
|
700
|
+
api_params["max_completion_tokens"] = tokens_to_use
|
|
701
|
+
# Note: Temperature parameter is OMITTED for reasoning models (not supported)
|
|
702
|
+
# Note: reasoning_effort is also OMITTED - without it, GPT-5 is much faster
|
|
703
|
+
print(f"🔵 Reasoning model params: max_completion_tokens={tokens_to_use}, no reasoning_effort (faster)")
|
|
704
|
+
else:
|
|
705
|
+
# Standard models (gpt-4o, gpt-4-turbo, etc.)
|
|
706
|
+
api_params["max_tokens"] = tokens_to_use
|
|
707
|
+
api_params["temperature"] = self.temperature
|
|
708
|
+
print(f"🔵 Standard model params: max_tokens={tokens_to_use}, temperature={self.temperature}")
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
print(f"🔵 Calling OpenAI API...")
|
|
712
|
+
response = client.chat.completions.create(**api_params)
|
|
713
|
+
print(f"🔵 OpenAI API call completed")
|
|
714
|
+
|
|
715
|
+
# Check if response has content
|
|
716
|
+
if not response.choices or not response.choices[0].message.content:
|
|
717
|
+
error_msg = f"OpenAI returned empty response for model {self.model}"
|
|
718
|
+
print(f"❌ ERROR: {error_msg}")
|
|
719
|
+
raise ValueError(error_msg)
|
|
720
|
+
|
|
721
|
+
translation = response.choices[0].message.content.strip()
|
|
722
|
+
|
|
723
|
+
# Check if translation is empty after stripping
|
|
724
|
+
if not translation:
|
|
725
|
+
error_msg = f"OpenAI returned empty translation after stripping for model {self.model}"
|
|
726
|
+
print(f"❌ ERROR: {error_msg}")
|
|
727
|
+
print(f"Raw response: {response.choices[0].message.content}")
|
|
728
|
+
raise ValueError(error_msg)
|
|
729
|
+
|
|
730
|
+
# Clean up translation: remove any prompt remnants
|
|
731
|
+
translation = self._clean_translation_response(translation, prompt)
|
|
732
|
+
|
|
733
|
+
return translation
|
|
734
|
+
|
|
735
|
+
except Exception as e:
|
|
736
|
+
# Log the actual error with context
|
|
737
|
+
print(f"❌ OpenAI API Error (model: {self.model})")
|
|
738
|
+
print(f" Error type: {type(e).__name__}")
|
|
739
|
+
print(f" Error message: {str(e)}")
|
|
740
|
+
print(f" Prompt length: {len(prompt)} characters")
|
|
741
|
+
if hasattr(e, 'response'):
|
|
742
|
+
print(f" Response: {e.response}")
|
|
743
|
+
raise # Re-raise to be caught by calling code
|
|
744
|
+
|
|
745
|
+
def _call_claude(self, prompt: str, max_tokens: Optional[int] = None, images: Optional[List] = None) -> str:
|
|
746
|
+
"""Call Anthropic Claude API with vision support"""
|
|
747
|
+
try:
|
|
748
|
+
import anthropic
|
|
749
|
+
except ImportError:
|
|
750
|
+
raise ImportError(
|
|
751
|
+
"Anthropic library not installed. Install with: pip install anthropic"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Use longer timeout for batch operations (detected by large prompts)
|
|
755
|
+
# Opus 4.1 can take longer to process, especially with extended context
|
|
756
|
+
prompt_length = len(prompt)
|
|
757
|
+
if prompt_length > 50000: # Large batch prompt
|
|
758
|
+
timeout_seconds = 300.0 # 5 minutes for very large prompts
|
|
759
|
+
elif prompt_length > 20000: # Medium batch prompt
|
|
760
|
+
timeout_seconds = 180.0 # 3 minutes
|
|
761
|
+
else:
|
|
762
|
+
timeout_seconds = 120.0 # 2 minutes for normal operations
|
|
763
|
+
|
|
764
|
+
client = anthropic.Anthropic(api_key=self.api_key, timeout=timeout_seconds)
|
|
765
|
+
|
|
766
|
+
# Use provided max_tokens or default (Claude uses 4096 as default)
|
|
767
|
+
tokens_to_use = max_tokens if max_tokens is not None else self.max_tokens
|
|
768
|
+
|
|
769
|
+
# Build message content (text + optional images)
|
|
770
|
+
if images:
|
|
771
|
+
# Claude vision format: content as array with text and image objects
|
|
772
|
+
content = []
|
|
773
|
+
for img_ref, img_base64 in images:
|
|
774
|
+
content.append({
|
|
775
|
+
"type": "image",
|
|
776
|
+
"source": {
|
|
777
|
+
"type": "base64",
|
|
778
|
+
"media_type": "image/png",
|
|
779
|
+
"data": img_base64
|
|
780
|
+
}
|
|
781
|
+
})
|
|
782
|
+
# Add text after images
|
|
783
|
+
content.append({"type": "text", "text": prompt})
|
|
784
|
+
print(f"🟣 Claude vision mode: {len(images)} images added to message")
|
|
785
|
+
else:
|
|
786
|
+
# Standard text-only format
|
|
787
|
+
content = prompt
|
|
788
|
+
|
|
789
|
+
response = client.messages.create(
|
|
790
|
+
model=self.model,
|
|
791
|
+
max_tokens=tokens_to_use,
|
|
792
|
+
messages=[{"role": "user", "content": content}],
|
|
793
|
+
timeout=timeout_seconds # Explicit timeout
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
translation = response.content[0].text.strip()
|
|
797
|
+
|
|
798
|
+
# Clean up translation: remove any prompt remnants
|
|
799
|
+
translation = self._clean_translation_response(translation, prompt)
|
|
800
|
+
|
|
801
|
+
return translation
|
|
802
|
+
|
|
803
|
+
def _call_gemini(self, prompt: str, max_tokens: Optional[int] = None, images: Optional[List] = None) -> str:
|
|
804
|
+
"""Call Google Gemini API with vision support"""
|
|
805
|
+
try:
|
|
806
|
+
import google.generativeai as genai
|
|
807
|
+
from PIL import Image
|
|
808
|
+
except ImportError:
|
|
809
|
+
raise ImportError(
|
|
810
|
+
"Google AI library not installed. Install with: pip install google-generativeai pillow"
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
genai.configure(api_key=self.api_key)
|
|
814
|
+
model = genai.GenerativeModel(self.model)
|
|
815
|
+
|
|
816
|
+
# Build content (text + optional images)
|
|
817
|
+
if images:
|
|
818
|
+
# Gemini format: list with prompt text followed by PIL Image objects
|
|
819
|
+
content = [prompt]
|
|
820
|
+
for img_ref, pil_image in images:
|
|
821
|
+
content.append(pil_image) # Gemini accepts PIL.Image directly
|
|
822
|
+
print(f"🟢 Gemini vision mode: {len(images)} images added to message")
|
|
823
|
+
else:
|
|
824
|
+
# Standard text-only
|
|
825
|
+
content = prompt
|
|
826
|
+
|
|
827
|
+
response = model.generate_content(content)
|
|
828
|
+
translation = response.text.strip()
|
|
829
|
+
|
|
830
|
+
# Clean up translation: remove any prompt remnants
|
|
831
|
+
translation = self._clean_translation_response(translation, prompt)
|
|
832
|
+
|
|
833
|
+
return translation
|
|
834
|
+
|
|
835
|
+
def _call_ollama(self, prompt: str, max_tokens: Optional[int] = None) -> str:
|
|
836
|
+
"""
|
|
837
|
+
Call local Ollama server for translation.
|
|
838
|
+
|
|
839
|
+
Ollama provides a simple REST API compatible with local LLM inference.
|
|
840
|
+
Models run entirely on the user's computer - no API keys, no internet required.
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
prompt: The full prompt to send
|
|
844
|
+
max_tokens: Maximum tokens to generate (default: 4096)
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
Translated text
|
|
848
|
+
|
|
849
|
+
Raises:
|
|
850
|
+
ConnectionError: If Ollama is not running
|
|
851
|
+
ValueError: If model is not available
|
|
852
|
+
"""
|
|
853
|
+
try:
|
|
854
|
+
import requests
|
|
855
|
+
except ImportError:
|
|
856
|
+
raise ImportError(
|
|
857
|
+
"Requests library not installed. Install with: pip install requests"
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
# Get Ollama endpoint from environment or use default
|
|
861
|
+
endpoint = os.environ.get('OLLAMA_ENDPOINT', 'http://localhost:11434')
|
|
862
|
+
|
|
863
|
+
# Use provided max_tokens or default
|
|
864
|
+
tokens_to_use = max_tokens if max_tokens is not None else min(self.max_tokens, 8192)
|
|
865
|
+
|
|
866
|
+
print(f"🟠 _call_ollama START: model={self.model}, prompt_len={len(prompt)}, max_tokens={tokens_to_use}")
|
|
867
|
+
print(f"🟠 Ollama endpoint: {endpoint}")
|
|
868
|
+
|
|
869
|
+
# Build request payload
|
|
870
|
+
# Using /api/chat for chat-style interaction (better for translation prompts)
|
|
871
|
+
payload = {
|
|
872
|
+
"model": self.model,
|
|
873
|
+
"messages": [
|
|
874
|
+
{"role": "user", "content": prompt}
|
|
875
|
+
],
|
|
876
|
+
"stream": False, # Get complete response at once
|
|
877
|
+
"options": {
|
|
878
|
+
"temperature": 0.3, # Low temperature for consistent translations
|
|
879
|
+
"num_predict": tokens_to_use,
|
|
880
|
+
"top_p": 0.9,
|
|
881
|
+
"repeat_penalty": 1.1
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
try:
|
|
886
|
+
# Make API call with generous timeout (local models can be slow, especially first load)
|
|
887
|
+
# First call loads model into memory which can take 30-60 seconds
|
|
888
|
+
# Large models (14B+) on CPU can take 2-5 minutes per translation
|
|
889
|
+
print(f"🟠 Calling Ollama API...")
|
|
890
|
+
|
|
891
|
+
# Determine timeout based on model size
|
|
892
|
+
model_lower = self.model.lower()
|
|
893
|
+
if '14b' in model_lower or '13b' in model_lower or '20b' in model_lower:
|
|
894
|
+
timeout_seconds = 600 # 10 minutes for large models on CPU
|
|
895
|
+
elif '7b' in model_lower or '8b' in model_lower or '9b' in model_lower:
|
|
896
|
+
timeout_seconds = 300 # 5 minutes for medium models
|
|
897
|
+
else:
|
|
898
|
+
timeout_seconds = 180 # 3 minutes for small models
|
|
899
|
+
|
|
900
|
+
response = requests.post(
|
|
901
|
+
f"{endpoint}/api/chat",
|
|
902
|
+
json=payload,
|
|
903
|
+
timeout=timeout_seconds
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
if response.status_code == 404:
|
|
907
|
+
raise ValueError(
|
|
908
|
+
f"Model '{self.model}' not found in Ollama. "
|
|
909
|
+
f"Please download it first with: ollama pull {self.model}"
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
response.raise_for_status()
|
|
913
|
+
|
|
914
|
+
result = response.json()
|
|
915
|
+
print(f"🟠 Ollama API call completed")
|
|
916
|
+
|
|
917
|
+
# Extract translation from response
|
|
918
|
+
if 'message' in result and 'content' in result['message']:
|
|
919
|
+
translation = result['message']['content'].strip()
|
|
920
|
+
else:
|
|
921
|
+
raise ValueError(f"Unexpected Ollama response format: {result}")
|
|
922
|
+
|
|
923
|
+
# Log some stats if available
|
|
924
|
+
if 'eval_count' in result:
|
|
925
|
+
print(f"🟠 Ollama stats: {result.get('eval_count', 0)} tokens generated")
|
|
926
|
+
|
|
927
|
+
# Clean up translation: remove any prompt remnants
|
|
928
|
+
translation = self._clean_translation_response(translation, prompt)
|
|
929
|
+
|
|
930
|
+
return translation
|
|
931
|
+
|
|
932
|
+
except requests.exceptions.ConnectionError:
|
|
933
|
+
raise ConnectionError(
|
|
934
|
+
f"Cannot connect to Ollama at {endpoint}. "
|
|
935
|
+
"Please ensure Ollama is installed and running.\n\n"
|
|
936
|
+
"To start Ollama:\n"
|
|
937
|
+
" 1. Install from https://ollama.com\n"
|
|
938
|
+
" 2. Run 'ollama serve' in a terminal\n"
|
|
939
|
+
" 3. Try again"
|
|
940
|
+
)
|
|
941
|
+
except requests.exceptions.Timeout:
|
|
942
|
+
raise TimeoutError(
|
|
943
|
+
f"Ollama request timed out after {timeout_seconds} seconds.\n\n"
|
|
944
|
+
"This usually means:\n"
|
|
945
|
+
" 1. System is low on RAM (check Task Manager)\n"
|
|
946
|
+
" 2. Model is too large for your hardware\n"
|
|
947
|
+
" 3. First-time model loading takes longer\n\n"
|
|
948
|
+
"Solutions:\n"
|
|
949
|
+
" • Close other applications to free RAM\n"
|
|
950
|
+
" • Use a smaller model: 'qwen2.5:7b' or 'qwen2.5:3b'\n"
|
|
951
|
+
" • Try again (subsequent runs are faster)"
|
|
952
|
+
)
|
|
953
|
+
except requests.exceptions.RequestException as e:
|
|
954
|
+
raise RuntimeError(f"Ollama API error: {str(e)}")
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# ============================================================================
|
|
958
|
+
# STANDALONE USAGE
|
|
959
|
+
# ============================================================================
|
|
960
|
+
|
|
961
|
+
def main():
|
|
962
|
+
"""Example standalone usage of LLM client"""
|
|
963
|
+
import sys
|
|
964
|
+
|
|
965
|
+
if len(sys.argv) < 4:
|
|
966
|
+
print("Usage: python llm_clients.py <provider> <api_key> <text_to_translate>")
|
|
967
|
+
print("Example: python llm_clients.py openai sk-... 'Hello world'")
|
|
968
|
+
sys.exit(1)
|
|
969
|
+
|
|
970
|
+
provider = sys.argv[1]
|
|
971
|
+
api_key = sys.argv[2]
|
|
972
|
+
text = sys.argv[3]
|
|
973
|
+
|
|
974
|
+
# Create client
|
|
975
|
+
client = LLMClient(api_key=api_key, provider=provider)
|
|
976
|
+
|
|
977
|
+
# Translate
|
|
978
|
+
print(f"Translating with {provider} ({client.model})...")
|
|
979
|
+
result = client.translate(text, source_lang="en", target_lang="nl")
|
|
980
|
+
|
|
981
|
+
print(f"\nOriginal: {text}")
|
|
982
|
+
print(f"Translation: {result}")
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# Wrapper functions for easy integration with Supervertaler
|
|
986
|
+
def get_openai_translation(text: str, source_lang: str, target_lang: str, context: str = "") -> Dict:
|
|
987
|
+
"""
|
|
988
|
+
Get OpenAI translation with metadata
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
text: Text to translate
|
|
992
|
+
source_lang: Source language name
|
|
993
|
+
target_lang: Target language name
|
|
994
|
+
context: Optional context for better translation
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Dict with translation, model, and metadata
|
|
998
|
+
"""
|
|
999
|
+
try:
|
|
1000
|
+
print(f"🔍 [DEBUG] OpenAI: Starting translation for '{text[:30]}...'")
|
|
1001
|
+
|
|
1002
|
+
# Load API key from config
|
|
1003
|
+
api_key = _load_api_key('openai')
|
|
1004
|
+
print(f"🔍 [DEBUG] OpenAI: API key loaded: {'Yes' if api_key else 'No'}")
|
|
1005
|
+
if not api_key:
|
|
1006
|
+
raise ValueError("OpenAI API key not found in api_keys.txt")
|
|
1007
|
+
|
|
1008
|
+
# Create LLM client and get real translation
|
|
1009
|
+
print(f"🔍 [DEBUG] OpenAI: Creating LLMClient...")
|
|
1010
|
+
client = LLMClient(api_key=api_key, provider="openai")
|
|
1011
|
+
print(f"🔍 [DEBUG] OpenAI: Client created, calling translate...")
|
|
1012
|
+
|
|
1013
|
+
translation = client.translate(
|
|
1014
|
+
text=text,
|
|
1015
|
+
source_lang=_convert_lang_name_to_code(source_lang),
|
|
1016
|
+
target_lang=_convert_lang_name_to_code(target_lang),
|
|
1017
|
+
context=context if context else None
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
print(f"🔍 [DEBUG] OpenAI: Translation received: '{translation[:30]}...'")
|
|
1021
|
+
return {
|
|
1022
|
+
'translation': translation,
|
|
1023
|
+
'model': client.model,
|
|
1024
|
+
'explanation': f"Translation provided with context: {context[:50]}..." if context else "Translation completed",
|
|
1025
|
+
'success': True
|
|
1026
|
+
}
|
|
1027
|
+
except Exception as e:
|
|
1028
|
+
print(f"🔍 [DEBUG] OpenAI: ERROR - {str(e)}")
|
|
1029
|
+
return {
|
|
1030
|
+
'translation': None,
|
|
1031
|
+
'error': str(e),
|
|
1032
|
+
'success': False
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def get_claude_translation(text: str, source_lang: str, target_lang: str, context: str = "") -> Dict:
|
|
1037
|
+
"""
|
|
1038
|
+
Get Claude translation with metadata
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
text: Text to translate
|
|
1042
|
+
source_lang: Source language name
|
|
1043
|
+
target_lang: Target language name
|
|
1044
|
+
context: Optional context for better translation
|
|
1045
|
+
|
|
1046
|
+
Returns:
|
|
1047
|
+
Dict with translation, model, and metadata
|
|
1048
|
+
"""
|
|
1049
|
+
try:
|
|
1050
|
+
print(f"🔍 [DEBUG] Claude: Starting translation for '{text[:30]}...'")
|
|
1051
|
+
|
|
1052
|
+
# Load API key from config
|
|
1053
|
+
api_key = _load_api_key('claude')
|
|
1054
|
+
print(f"🔍 [DEBUG] Claude: API key loaded: {'Yes' if api_key else 'No'}")
|
|
1055
|
+
if not api_key:
|
|
1056
|
+
raise ValueError("Claude API key not found in api_keys.txt")
|
|
1057
|
+
|
|
1058
|
+
# Create LLM client and get real translation
|
|
1059
|
+
print(f"🔍 [DEBUG] Claude: Creating LLMClient...")
|
|
1060
|
+
client = LLMClient(api_key=api_key, provider="claude")
|
|
1061
|
+
print(f"🔍 [DEBUG] Claude: Client created, calling translate...")
|
|
1062
|
+
|
|
1063
|
+
translation = client.translate(
|
|
1064
|
+
text=text,
|
|
1065
|
+
source_lang=_convert_lang_name_to_code(source_lang),
|
|
1066
|
+
target_lang=_convert_lang_name_to_code(target_lang),
|
|
1067
|
+
context=context if context else None
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
print(f"🔍 [DEBUG] Claude: Translation received: '{translation[:30]}...'")
|
|
1071
|
+
return {
|
|
1072
|
+
'translation': translation,
|
|
1073
|
+
'model': client.model,
|
|
1074
|
+
'reasoning': f"High-quality translation considering context: {context[:50]}..." if context else "Translation completed",
|
|
1075
|
+
'success': True
|
|
1076
|
+
}
|
|
1077
|
+
except Exception as e:
|
|
1078
|
+
print(f"🔍 [DEBUG] Claude: ERROR - {str(e)}")
|
|
1079
|
+
return {
|
|
1080
|
+
'translation': None,
|
|
1081
|
+
'error': str(e),
|
|
1082
|
+
'success': False
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _load_api_key(provider: str) -> str:
|
|
1087
|
+
"""Load API key from api_keys.txt file"""
|
|
1088
|
+
try:
|
|
1089
|
+
import os
|
|
1090
|
+
api_keys_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'api_keys.txt')
|
|
1091
|
+
|
|
1092
|
+
if not os.path.exists(api_keys_path):
|
|
1093
|
+
return None
|
|
1094
|
+
|
|
1095
|
+
with open(api_keys_path, 'r') as f:
|
|
1096
|
+
for line in f:
|
|
1097
|
+
line = line.strip()
|
|
1098
|
+
if line and not line.startswith('#') and '=' in line:
|
|
1099
|
+
key_name, key_value = line.split('=', 1)
|
|
1100
|
+
if key_name.strip().lower() == provider.lower():
|
|
1101
|
+
return key_value.strip()
|
|
1102
|
+
return None
|
|
1103
|
+
except Exception:
|
|
1104
|
+
return None
|
|
1105
|
+
|
|
1106
|
+
def _convert_lang_name_to_code(lang_name: str) -> str:
|
|
1107
|
+
"""Convert language names to codes for LLM API"""
|
|
1108
|
+
lang_map = {
|
|
1109
|
+
'Dutch': 'nl',
|
|
1110
|
+
'English': 'en',
|
|
1111
|
+
'German': 'de',
|
|
1112
|
+
'French': 'fr',
|
|
1113
|
+
'Spanish': 'es',
|
|
1114
|
+
'Italian': 'it',
|
|
1115
|
+
'Portuguese': 'pt',
|
|
1116
|
+
'Chinese': 'zh',
|
|
1117
|
+
'Japanese': 'ja',
|
|
1118
|
+
'Korean': 'ko'
|
|
1119
|
+
}
|
|
1120
|
+
return lang_map.get(lang_name, lang_name.lower()[:2])
|
|
1121
|
+
|
|
1122
|
+
def get_google_translation(text: str, source_lang: str, target_lang: str) -> Dict:
|
|
1123
|
+
"""
|
|
1124
|
+
Get Google Cloud Translation API translation with metadata
|
|
1125
|
+
|
|
1126
|
+
Args:
|
|
1127
|
+
text: Text to translate
|
|
1128
|
+
source_lang: Source language code (e.g., 'en', 'nl', 'auto')
|
|
1129
|
+
target_lang: Target language code (e.g., 'en', 'nl')
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
Dict with translation, confidence, and metadata
|
|
1133
|
+
"""
|
|
1134
|
+
try:
|
|
1135
|
+
# Load API key from api_keys.txt
|
|
1136
|
+
api_keys = load_api_keys()
|
|
1137
|
+
# Try both 'google_translate' and 'google' for backward compatibility
|
|
1138
|
+
google_api_key = api_keys.get('google_translate') or api_keys.get('google')
|
|
1139
|
+
|
|
1140
|
+
if not google_api_key:
|
|
1141
|
+
return {
|
|
1142
|
+
'translation': None,
|
|
1143
|
+
'error': 'Google Translate API key not found in api_keys.txt (looking for "google_translate" or "google")',
|
|
1144
|
+
'success': False
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
# Use Google Cloud Translation API (Basic/v2) via REST
|
|
1148
|
+
try:
|
|
1149
|
+
import requests
|
|
1150
|
+
|
|
1151
|
+
# Use REST API directly with API key
|
|
1152
|
+
url = "https://translation.googleapis.com/language/translate/v2"
|
|
1153
|
+
|
|
1154
|
+
# Handle 'auto' source language
|
|
1155
|
+
params = {
|
|
1156
|
+
'key': google_api_key,
|
|
1157
|
+
'q': text,
|
|
1158
|
+
'target': target_lang
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if source_lang and source_lang != 'auto':
|
|
1162
|
+
params['source'] = source_lang
|
|
1163
|
+
|
|
1164
|
+
# Make API request
|
|
1165
|
+
response = requests.post(url, params=params)
|
|
1166
|
+
|
|
1167
|
+
if response.status_code == 200:
|
|
1168
|
+
result = response.json()
|
|
1169
|
+
if 'data' in result and 'translations' in result['data']:
|
|
1170
|
+
translation_data = result['data']['translations'][0]
|
|
1171
|
+
return {
|
|
1172
|
+
'translation': translation_data['translatedText'],
|
|
1173
|
+
'confidence': 'High',
|
|
1174
|
+
'detected_source_language': translation_data.get('detectedSourceLanguage', source_lang),
|
|
1175
|
+
'provider': 'Google Cloud Translation',
|
|
1176
|
+
'success': True,
|
|
1177
|
+
'metadata': {
|
|
1178
|
+
'model': 'nmt', # Neural Machine Translation
|
|
1179
|
+
'input': text
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
else:
|
|
1183
|
+
return {
|
|
1184
|
+
'translation': None,
|
|
1185
|
+
'error': f'Unexpected Google API response format: {result}',
|
|
1186
|
+
'success': False
|
|
1187
|
+
}
|
|
1188
|
+
else:
|
|
1189
|
+
return {
|
|
1190
|
+
'translation': None,
|
|
1191
|
+
'error': f'Google API error: {response.status_code} - {response.text}',
|
|
1192
|
+
'success': False
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
except ImportError:
|
|
1196
|
+
# Fallback if requests is not installed
|
|
1197
|
+
return {
|
|
1198
|
+
'translation': None,
|
|
1199
|
+
'error': 'Requests library not installed. Install: pip install requests',
|
|
1200
|
+
'success': False
|
|
1201
|
+
}
|
|
1202
|
+
except Exception as e:
|
|
1203
|
+
return {
|
|
1204
|
+
'translation': None,
|
|
1205
|
+
'error': f'Google Translate error: {str(e)}',
|
|
1206
|
+
'success': False
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
if __name__ == "__main__":
|
|
1211
|
+
main()
|