better_translate 0.5.0 → 1.0.0
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.
- checksums.yaml +4 -4
- data/.env.example +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/.yardopts +10 -0
- data/CHANGELOG.md +125 -114
- data/CLAUDE.md +385 -0
- data/README.md +629 -244
- data/Rakefile +7 -1
- data/Steepfile +29 -0
- data/docs/implementation/00-overview.md +220 -0
- data/docs/implementation/01-setup_dependencies.md +668 -0
- data/docs/implementation/02-error_handling.md +65 -0
- data/docs/implementation/03-core_components.md +457 -0
- data/docs/implementation/03.5-variable_preservation.md +509 -0
- data/docs/implementation/04-provider_architecture.md +571 -0
- data/docs/implementation/05-translation_logic.md +1065 -0
- data/docs/implementation/06-main_module_api.md +122 -0
- data/docs/implementation/07-direct_translation_helpers.md +582 -0
- data/docs/implementation/08-rails_integration.md +323 -0
- data/docs/implementation/09-testing_suite.md +228 -0
- data/docs/implementation/10-documentation_examples.md +150 -0
- data/docs/implementation/11-quality_security.md +65 -0
- data/docs/implementation/12-cli_standalone.md +698 -0
- data/exe/better_translate +9 -0
- data/lib/better_translate/cache.rb +125 -0
- data/lib/better_translate/cli.rb +304 -0
- data/lib/better_translate/configuration.rb +201 -0
- data/lib/better_translate/direct_translator.rb +131 -0
- data/lib/better_translate/errors.rb +101 -0
- data/lib/better_translate/progress_tracker.rb +157 -0
- data/lib/better_translate/provider_factory.rb +45 -0
- data/lib/better_translate/providers/anthropic_provider.rb +154 -0
- data/lib/better_translate/providers/base_http_provider.rb +239 -0
- data/lib/better_translate/providers/chatgpt_provider.rb +138 -44
- data/lib/better_translate/providers/gemini_provider.rb +123 -61
- data/lib/better_translate/railtie.rb +18 -0
- data/lib/better_translate/rate_limiter.rb +90 -0
- data/lib/better_translate/strategies/base_strategy.rb +58 -0
- data/lib/better_translate/strategies/batch_strategy.rb +56 -0
- data/lib/better_translate/strategies/deep_strategy.rb +45 -0
- data/lib/better_translate/strategies/strategy_selector.rb +43 -0
- data/lib/better_translate/translator.rb +115 -284
- data/lib/better_translate/utils/hash_flattener.rb +104 -0
- data/lib/better_translate/validator.rb +105 -0
- data/lib/better_translate/variable_extractor.rb +259 -0
- data/lib/better_translate/version.rb +2 -9
- data/lib/better_translate/yaml_handler.rb +168 -0
- data/lib/better_translate.rb +97 -73
- data/lib/generators/better_translate/analyze/USAGE +12 -0
- data/lib/generators/better_translate/analyze/analyze_generator.rb +94 -0
- data/lib/generators/better_translate/install/USAGE +13 -0
- data/lib/generators/better_translate/install/install_generator.rb +71 -0
- data/lib/generators/better_translate/install/templates/README +20 -0
- data/lib/generators/better_translate/install/templates/initializer.rb.tt +47 -0
- data/lib/generators/better_translate/translate/USAGE +13 -0
- data/lib/generators/better_translate/translate/translate_generator.rb +114 -0
- data/lib/tasks/better_translate.rake +136 -0
- data/sig/better_translate/cache.rbs +28 -0
- data/sig/better_translate/cli.rbs +24 -0
- data/sig/better_translate/configuration.rbs +78 -0
- data/sig/better_translate/direct_translator.rbs +18 -0
- data/sig/better_translate/errors.rbs +46 -0
- data/sig/better_translate/progress_tracker.rbs +29 -0
- data/sig/better_translate/provider_factory.rbs +8 -0
- data/sig/better_translate/providers/anthropic_provider.rbs +27 -0
- data/sig/better_translate/providers/base_http_provider.rbs +44 -0
- data/sig/better_translate/providers/chatgpt_provider.rbs +25 -0
- data/sig/better_translate/providers/gemini_provider.rbs +22 -0
- data/sig/better_translate/railtie.rbs +7 -0
- data/sig/better_translate/rate_limiter.rbs +20 -0
- data/sig/better_translate/strategies/base_strategy.rbs +19 -0
- data/sig/better_translate/strategies/batch_strategy.rbs +13 -0
- data/sig/better_translate/strategies/deep_strategy.rbs +11 -0
- data/sig/better_translate/strategies/strategy_selector.rbs +10 -0
- data/sig/better_translate/translator.rbs +24 -0
- data/sig/better_translate/utils/hash_flattener.rbs +14 -0
- data/sig/better_translate/validator.rbs +14 -0
- data/sig/better_translate/variable_extractor.rbs +40 -0
- data/sig/better_translate/version.rbs +4 -0
- data/sig/better_translate/yaml_handler.rbs +29 -0
- data/sig/better_translate.rbs +32 -2
- data/sig/faraday.rbs +22 -0
- data/sig/generators/better_translate/analyze/analyze_generator.rbs +18 -0
- data/sig/generators/better_translate/install/install_generator.rbs +14 -0
- data/sig/generators/better_translate/translate/translate_generator.rbs +10 -0
- data/sig/optparse.rbs +9 -0
- data/sig/psych.rbs +5 -0
- data/sig/rails.rbs +34 -0
- metadata +89 -203
- data/lib/better_translate/helper.rb +0 -83
- data/lib/better_translate/providers/base_provider.rb +0 -102
- data/lib/better_translate/service.rb +0 -144
- data/lib/better_translate/similarity_analyzer.rb +0 -218
- data/lib/better_translate/utils.rb +0 -55
- data/lib/better_translate/writer.rb +0 -75
- data/lib/generators/better_translate/analyze_generator.rb +0 -57
- data/lib/generators/better_translate/install_generator.rb +0 -14
- data/lib/generators/better_translate/templates/better_translate.rb +0 -56
- data/lib/generators/better_translate/translate_generator.rb +0 -84
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module BetterTranslate
|
|
7
|
+
# Translation provider implementations
|
|
8
|
+
#
|
|
9
|
+
# Contains all AI provider integrations (ChatGPT, Gemini, Anthropic)
|
|
10
|
+
# and the base HTTP provider class.
|
|
11
|
+
module Providers
|
|
12
|
+
# Base class for HTTP-based translation providers
|
|
13
|
+
#
|
|
14
|
+
# Implements common functionality:
|
|
15
|
+
# - Faraday HTTP client with retry logic
|
|
16
|
+
# - Exponential backoff with jitter
|
|
17
|
+
# - Rate limiting
|
|
18
|
+
# - Caching
|
|
19
|
+
# - Error handling
|
|
20
|
+
#
|
|
21
|
+
# @abstract Subclasses must implement {#translate_text} and {#translate_batch}
|
|
22
|
+
#
|
|
23
|
+
# @example Creating a custom provider
|
|
24
|
+
# class MyProvider < BaseHttpProvider
|
|
25
|
+
# def translate_text(text, target_lang_code, target_lang_name)
|
|
26
|
+
# # Implementation
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def translate_batch(texts, target_lang_code, target_lang_name)
|
|
30
|
+
# # Implementation
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
class BaseHttpProvider
|
|
35
|
+
# @return [Configuration] The configuration object
|
|
36
|
+
attr_reader :config
|
|
37
|
+
|
|
38
|
+
# @return [Cache] The cache instance
|
|
39
|
+
attr_reader :cache
|
|
40
|
+
|
|
41
|
+
# @return [RateLimiter] The rate limiter instance
|
|
42
|
+
attr_reader :rate_limiter
|
|
43
|
+
|
|
44
|
+
# Initialize the provider
|
|
45
|
+
#
|
|
46
|
+
# @param config [Configuration] Configuration object
|
|
47
|
+
#
|
|
48
|
+
# @example
|
|
49
|
+
# config = Configuration.new
|
|
50
|
+
# provider = BaseHttpProvider.new(config)
|
|
51
|
+
#
|
|
52
|
+
def initialize(config)
|
|
53
|
+
@config = config
|
|
54
|
+
@cache = Cache.new(capacity: config.cache_size, ttl: config.cache_ttl)
|
|
55
|
+
@rate_limiter = RateLimiter.new(delay: 0.5)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Translate a single text string
|
|
59
|
+
#
|
|
60
|
+
# @param text [String] Text to translate
|
|
61
|
+
# @param target_lang_code [String] Target language code
|
|
62
|
+
# @param target_lang_name [String] Target language name
|
|
63
|
+
# @return [String] Translated text
|
|
64
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# provider.translate_text("Hello", "it", "Italian")
|
|
68
|
+
# #=> "Ciao"
|
|
69
|
+
#
|
|
70
|
+
def translate_text(text, target_lang_code, target_lang_name)
|
|
71
|
+
raise NotImplementedError, "#{self.class} must implement #translate_text"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Translate multiple texts in a batch
|
|
75
|
+
#
|
|
76
|
+
# @param texts [Array<String>] Texts to translate
|
|
77
|
+
# @param target_lang_code [String] Target language code
|
|
78
|
+
# @param target_lang_name [String] Target language name
|
|
79
|
+
# @return [Array<String>] Translated texts
|
|
80
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
|
81
|
+
#
|
|
82
|
+
# @example
|
|
83
|
+
# provider.translate_batch(["Hello", "World"], "it", "Italian")
|
|
84
|
+
# #=> ["Ciao", "Mondo"]
|
|
85
|
+
#
|
|
86
|
+
def translate_batch(texts, target_lang_code, target_lang_name)
|
|
87
|
+
raise NotImplementedError, "#{self.class} must implement #translate_batch"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
protected
|
|
91
|
+
|
|
92
|
+
# Make an HTTP request with retry logic
|
|
93
|
+
#
|
|
94
|
+
# Implements exponential backoff with jitter and rate limiting.
|
|
95
|
+
# Automatically retries on rate limit and API errors.
|
|
96
|
+
#
|
|
97
|
+
# @param method [Symbol] HTTP method (:get, :post, etc.)
|
|
98
|
+
# @param url [String] Request URL
|
|
99
|
+
# @param body [Hash, nil] Request body
|
|
100
|
+
# @param headers [Hash] Request headers
|
|
101
|
+
# @return [Faraday::Response] HTTP response
|
|
102
|
+
# @raise [ApiError] if request fails after retries
|
|
103
|
+
# @api private
|
|
104
|
+
#
|
|
105
|
+
def make_request(method, url, body: nil, headers: {})
|
|
106
|
+
attempt = 0
|
|
107
|
+
|
|
108
|
+
loop do
|
|
109
|
+
attempt += 1
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
rate_limiter.wait
|
|
113
|
+
response = http_client.send(method, url) do |req|
|
|
114
|
+
req.headers.merge!(headers)
|
|
115
|
+
req.body = body.to_json if body
|
|
116
|
+
end
|
|
117
|
+
rate_limiter.record_request
|
|
118
|
+
|
|
119
|
+
handle_response(response)
|
|
120
|
+
return response
|
|
121
|
+
rescue RateLimitError, ApiError => e
|
|
122
|
+
raise if attempt >= config.max_retries
|
|
123
|
+
|
|
124
|
+
delay = calculate_backoff(attempt)
|
|
125
|
+
log_retry(attempt, delay, e)
|
|
126
|
+
sleep(delay)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Handle HTTP response
|
|
132
|
+
#
|
|
133
|
+
# @param response [Faraday::Response] HTTP response
|
|
134
|
+
# @raise [RateLimitError] if rate limited (429)
|
|
135
|
+
# @raise [ApiError] if response indicates error
|
|
136
|
+
# @return [void]
|
|
137
|
+
# @api private
|
|
138
|
+
#
|
|
139
|
+
def handle_response(response)
|
|
140
|
+
case response.status
|
|
141
|
+
when 200..299
|
|
142
|
+
# Success
|
|
143
|
+
when 429
|
|
144
|
+
raise RateLimitError.new(
|
|
145
|
+
"Rate limit exceeded",
|
|
146
|
+
context: { status: response.status, body: response.body }
|
|
147
|
+
)
|
|
148
|
+
when 400..499
|
|
149
|
+
raise ApiError.new(
|
|
150
|
+
"Client error: #{response.status}",
|
|
151
|
+
context: { status: response.status, body: response.body }
|
|
152
|
+
)
|
|
153
|
+
when 500..599
|
|
154
|
+
raise ApiError.new(
|
|
155
|
+
"Server error: #{response.status}",
|
|
156
|
+
context: { status: response.status, body: response.body }
|
|
157
|
+
)
|
|
158
|
+
else
|
|
159
|
+
raise ApiError.new(
|
|
160
|
+
"Unexpected status: #{response.status}",
|
|
161
|
+
context: { status: response.status, body: response.body }
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Calculate exponential backoff with jitter
|
|
167
|
+
#
|
|
168
|
+
# @param attempt [Integer] Current attempt number
|
|
169
|
+
# @return [Float] Delay in seconds
|
|
170
|
+
# @api private
|
|
171
|
+
#
|
|
172
|
+
def calculate_backoff(attempt)
|
|
173
|
+
base_delay = config.retry_delay
|
|
174
|
+
max_delay = 60.0
|
|
175
|
+
jitter = rand * 0.3 # 0-30% jitter
|
|
176
|
+
|
|
177
|
+
delay = base_delay * (2**(attempt - 1)) * (1 + jitter)
|
|
178
|
+
[delay, max_delay].min
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Log retry attempt
|
|
182
|
+
#
|
|
183
|
+
# @param attempt [Integer] Current attempt number
|
|
184
|
+
# @param delay [Float] Delay before retry
|
|
185
|
+
# @param error [StandardError] The error that triggered retry
|
|
186
|
+
# @return [void]
|
|
187
|
+
# @api private
|
|
188
|
+
#
|
|
189
|
+
def log_retry(attempt, delay, error)
|
|
190
|
+
return unless config.verbose
|
|
191
|
+
|
|
192
|
+
puts "[BetterTranslate] Retry #{attempt}/#{config.max_retries} " \
|
|
193
|
+
"after #{delay.round(2)}s (#{error.class}: #{error.message})"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Get or create HTTP client
|
|
197
|
+
#
|
|
198
|
+
# @return [Faraday::Connection] HTTP client
|
|
199
|
+
# @api private
|
|
200
|
+
#
|
|
201
|
+
def http_client
|
|
202
|
+
@http_client ||= Faraday.new do |f|
|
|
203
|
+
f.options.timeout = config.request_timeout
|
|
204
|
+
f.options.open_timeout = 10
|
|
205
|
+
f.adapter Faraday.default_adapter
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Get from cache or execute block
|
|
210
|
+
#
|
|
211
|
+
# @param cache_key [String] Cache key
|
|
212
|
+
# @yieldreturn [String] Value to cache if not found
|
|
213
|
+
# @return [String] Cached or newly computed value
|
|
214
|
+
# @api private
|
|
215
|
+
#
|
|
216
|
+
def with_cache(cache_key)
|
|
217
|
+
return yield unless config.cache_enabled
|
|
218
|
+
|
|
219
|
+
cached = cache.get(cache_key)
|
|
220
|
+
return cached if cached
|
|
221
|
+
|
|
222
|
+
result = yield
|
|
223
|
+
cache.set(cache_key, result)
|
|
224
|
+
result
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Build cache key
|
|
228
|
+
#
|
|
229
|
+
# @param text [String] Text being translated
|
|
230
|
+
# @param target_lang_code [String] Target language code
|
|
231
|
+
# @return [String] Cache key
|
|
232
|
+
# @api private
|
|
233
|
+
#
|
|
234
|
+
def build_cache_key(text, target_lang_code)
|
|
235
|
+
"#{text}:#{target_lang_code}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -1,55 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module BetterTranslate
|
|
2
4
|
module Providers
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# Uses
|
|
5
|
+
# OpenAI ChatGPT translation provider
|
|
6
|
+
#
|
|
7
|
+
# Uses GPT-5-nano model with temperature=1.0 for natural translations.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# config = Configuration.new
|
|
11
|
+
# config.openai_key = ENV['OPENAI_API_KEY']
|
|
12
|
+
# provider = ChatGPTProvider.new(config)
|
|
13
|
+
# result = provider.translate_text("Hello", "it", "Italian")
|
|
14
|
+
# #=> "Ciao"
|
|
6
15
|
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# @
|
|
20
|
-
# @
|
|
16
|
+
class ChatGPTProvider < BaseHttpProvider
|
|
17
|
+
# OpenAI API endpoint
|
|
18
|
+
API_URL = "https://api.openai.com/v1/chat/completions"
|
|
19
|
+
|
|
20
|
+
# Model to use for translations
|
|
21
|
+
MODEL = "gpt-5-nano"
|
|
22
|
+
|
|
23
|
+
# Temperature setting for creativity
|
|
24
|
+
TEMPERATURE = 1.0
|
|
25
|
+
|
|
26
|
+
# Translate a single text
|
|
27
|
+
#
|
|
28
|
+
# @param text [String] Text to translate
|
|
29
|
+
# @param target_lang_code [String] Target language code (e.g., "it")
|
|
30
|
+
# @param target_lang_name [String] Target language name (e.g., "Italian")
|
|
31
|
+
# @return [String] Translated text
|
|
32
|
+
# @raise [ValidationError] if input is invalid
|
|
33
|
+
# @raise [TranslationError] if translation fails
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# provider.translate_text("Hello world", "it", "Italian")
|
|
37
|
+
# #=> "Ciao mondo"
|
|
38
|
+
#
|
|
21
39
|
def translate_text(text, target_lang_code, target_lang_name)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
40
|
+
Validator.validate_text!(text)
|
|
41
|
+
Validator.validate_language_code!(target_lang_code)
|
|
42
|
+
|
|
43
|
+
cache_key = build_cache_key(text, target_lang_code)
|
|
27
44
|
|
|
28
|
-
|
|
45
|
+
with_cache(cache_key) do
|
|
46
|
+
messages = build_messages(text, target_lang_name)
|
|
47
|
+
response = make_chat_completion_request(messages)
|
|
48
|
+
extract_translation(response)
|
|
49
|
+
end
|
|
50
|
+
rescue ApiError => e
|
|
51
|
+
raise TranslationError.new(
|
|
52
|
+
"Failed to translate text with ChatGPT: #{e.message}",
|
|
53
|
+
context: { text: text, target_lang: target_lang_code, original_error: e }
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Translate multiple texts in a batch
|
|
58
|
+
#
|
|
59
|
+
# @param texts [Array<String>] Texts to translate
|
|
60
|
+
# @param target_lang_code [String] Target language code
|
|
61
|
+
# @param target_lang_name [String] Target language name
|
|
62
|
+
# @return [Array<String>] Translated texts
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# provider.translate_batch(["Hello", "World"], "it", "Italian")
|
|
66
|
+
# #=> ["Ciao", "Mondo"]
|
|
67
|
+
#
|
|
68
|
+
def translate_batch(texts, target_lang_code, target_lang_name)
|
|
69
|
+
texts.map { |text| translate_text(text, target_lang_code, target_lang_name) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Build messages array for ChatGPT API
|
|
75
|
+
#
|
|
76
|
+
# @param text [String] Text to translate
|
|
77
|
+
# @param target_lang_name [String] Target language name
|
|
78
|
+
# @return [Array<Hash>] Messages array
|
|
79
|
+
# @api private
|
|
80
|
+
#
|
|
81
|
+
def build_messages(text, target_lang_name)
|
|
82
|
+
system_message = build_system_message(target_lang_name)
|
|
83
|
+
|
|
84
|
+
[
|
|
85
|
+
{ role: "system", content: system_message },
|
|
86
|
+
{ role: "user", content: text }
|
|
87
|
+
]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Build system message with optional context
|
|
91
|
+
#
|
|
92
|
+
# @param target_lang_name [String] Target language name
|
|
93
|
+
# @return [String] System message
|
|
94
|
+
# @api private
|
|
95
|
+
#
|
|
96
|
+
def build_system_message(target_lang_name)
|
|
97
|
+
base_message = "You are a professional translator. Translate the following text to #{target_lang_name}. " \
|
|
98
|
+
"Return ONLY the translated text, without any explanations or additional text."
|
|
99
|
+
|
|
100
|
+
if config.translation_context && !config.translation_context.empty?
|
|
101
|
+
base_message += "\n\nContext: #{config.translation_context}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
base_message
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Make chat completion request to OpenAI API
|
|
108
|
+
#
|
|
109
|
+
# @param messages [Array<Hash>] Messages array
|
|
110
|
+
# @return [Faraday::Response] HTTP response
|
|
111
|
+
# @api private
|
|
112
|
+
#
|
|
113
|
+
def make_chat_completion_request(messages)
|
|
29
114
|
body = {
|
|
30
|
-
model:
|
|
31
|
-
messages:
|
|
32
|
-
|
|
33
|
-
{ role: "user", content: "#{text}" }
|
|
34
|
-
],
|
|
35
|
-
temperature: 0.3
|
|
115
|
+
model: MODEL,
|
|
116
|
+
messages: messages,
|
|
117
|
+
temperature: TEMPERATURE
|
|
36
118
|
}
|
|
37
119
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
120
|
+
headers = {
|
|
121
|
+
"Content-Type" => "application/json",
|
|
122
|
+
"Authorization" => "Bearer #{config.openai_key}"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
make_request(:post, API_URL, body: body, headers: headers)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Extract translation from API response
|
|
129
|
+
#
|
|
130
|
+
# @param response [Faraday::Response] HTTP response
|
|
131
|
+
# @return [String] Translated text
|
|
132
|
+
# @raise [TranslationError] if parsing fails or no translation found
|
|
133
|
+
# @api private
|
|
134
|
+
#
|
|
135
|
+
def extract_translation(response)
|
|
136
|
+
parsed = JSON.parse(response.body)
|
|
137
|
+
translation = parsed.dig("choices", 0, "message", "content")
|
|
138
|
+
|
|
139
|
+
raise TranslationError, "No translation in response" if translation.nil? || translation.empty?
|
|
140
|
+
|
|
141
|
+
translation.strip
|
|
142
|
+
rescue JSON::ParserError => e
|
|
143
|
+
raise TranslationError.new(
|
|
144
|
+
"Failed to parse ChatGPT response",
|
|
145
|
+
context: { error: e.message, body: response.body }
|
|
146
|
+
)
|
|
53
147
|
end
|
|
54
148
|
end
|
|
55
149
|
end
|
|
@@ -1,75 +1,137 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "json"
|
|
5
|
-
require "uri"
|
|
6
|
-
|
|
7
3
|
module BetterTranslate
|
|
8
4
|
module Providers
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# Uses
|
|
5
|
+
# Google Gemini translation provider
|
|
6
|
+
#
|
|
7
|
+
# Uses gemini-2.0-flash-exp model for fast, high-quality translations.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# config = Configuration.new
|
|
11
|
+
# config.google_gemini_key = ENV['GOOGLE_GEMINI_KEY']
|
|
12
|
+
# provider = GeminiProvider.new(config)
|
|
13
|
+
# result = provider.translate_text("Hello", "it", "Italian")
|
|
14
|
+
# #=> "Ciao"
|
|
12
15
|
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
16
|
+
class GeminiProvider < BaseHttpProvider
|
|
17
|
+
# Google Gemini API endpoint
|
|
18
|
+
API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent"
|
|
19
|
+
|
|
20
|
+
# Model to use for translations
|
|
21
|
+
MODEL = "gemini-2.0-flash-exp"
|
|
22
|
+
|
|
23
|
+
# Translate a single text
|
|
24
|
+
#
|
|
25
|
+
# @param text [String] Text to translate
|
|
26
|
+
# @param target_lang_code [String] Target language code
|
|
27
|
+
# @param target_lang_name [String] Target language name
|
|
28
|
+
# @return [String] Translated text
|
|
29
|
+
# @raise [ValidationError] if input is invalid
|
|
30
|
+
# @raise [TranslationError] if translation fails
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# provider.translate_text("Hello world", "it", "Italian")
|
|
34
|
+
# #=> "Ciao mondo"
|
|
25
35
|
#
|
|
26
|
-
# @param text [String] The text to translate
|
|
27
|
-
# @param target_lang_code [String] The target language code (e.g., "fr")
|
|
28
|
-
# @param target_lang_name [String] The target language name (e.g., "French")
|
|
29
|
-
# @return [String] The translated text
|
|
30
|
-
# @raise [StandardError] If the API request fails or returns an error
|
|
31
36
|
def translate_text(text, target_lang_code, target_lang_name)
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
Validator.validate_text!(text)
|
|
38
|
+
Validator.validate_language_code!(target_lang_code)
|
|
39
|
+
|
|
40
|
+
cache_key = build_cache_key(text, target_lang_code)
|
|
41
|
+
|
|
42
|
+
with_cache(cache_key) do
|
|
43
|
+
prompt = build_prompt(text, target_lang_name)
|
|
44
|
+
response = make_generation_request(prompt)
|
|
45
|
+
extract_translation(response)
|
|
46
|
+
end
|
|
47
|
+
rescue ApiError => e
|
|
48
|
+
raise TranslationError.new(
|
|
49
|
+
"Failed to translate text with Gemini: #{e.message}",
|
|
50
|
+
context: { text: text, target_lang: target_lang_code, original_error: e }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Translate multiple texts in a batch
|
|
55
|
+
#
|
|
56
|
+
# @param texts [Array<String>] Texts to translate
|
|
57
|
+
# @param target_lang_code [String] Target language code
|
|
58
|
+
# @param target_lang_name [String] Target language name
|
|
59
|
+
# @return [Array<String>] Translated texts
|
|
60
|
+
#
|
|
61
|
+
# @example
|
|
62
|
+
# provider.translate_batch(["Hello", "World"], "it", "Italian")
|
|
63
|
+
# #=> ["Ciao", "Mondo"]
|
|
64
|
+
#
|
|
65
|
+
def translate_batch(texts, target_lang_code, target_lang_name)
|
|
66
|
+
texts.map { |text| translate_text(text, target_lang_code, target_lang_name) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Build prompt for Gemini API
|
|
72
|
+
#
|
|
73
|
+
# @param text [String] Text to translate
|
|
74
|
+
# @param target_lang_name [String] Target language name
|
|
75
|
+
# @return [String] Prompt
|
|
76
|
+
# @api private
|
|
77
|
+
#
|
|
78
|
+
def build_prompt(text, target_lang_name)
|
|
79
|
+
base_prompt = "Translate the following text to #{target_lang_name}. " \
|
|
80
|
+
"Return ONLY the translated text, without any explanations.\n\n" \
|
|
81
|
+
"Text: #{text}"
|
|
82
|
+
|
|
83
|
+
if config.translation_context && !config.translation_context.empty?
|
|
84
|
+
base_prompt = "Context: #{config.translation_context}\n\n#{base_prompt}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
base_prompt
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Make generation request to Gemini API
|
|
91
|
+
#
|
|
92
|
+
# @param prompt [String] Prompt text
|
|
93
|
+
# @return [Faraday::Response] HTTP response
|
|
94
|
+
# @api private
|
|
95
|
+
#
|
|
96
|
+
def make_generation_request(prompt)
|
|
97
|
+
url = "#{API_URL}?key=#{config.google_gemini_key}"
|
|
34
98
|
|
|
35
|
-
headers = { "Content-Type" => "application/json" }
|
|
36
99
|
body = {
|
|
37
|
-
contents: [
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
100
|
+
contents: [
|
|
101
|
+
{
|
|
102
|
+
parts: [
|
|
103
|
+
{ text: prompt }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
]
|
|
42
107
|
}
|
|
43
108
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
rescue => e
|
|
71
|
-
raise "Errore durante la traduzione con Gemini: #{e.message}"
|
|
72
|
-
end
|
|
109
|
+
headers = {
|
|
110
|
+
"Content-Type" => "application/json"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
make_request(:post, url, body: body, headers: headers)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Extract translation from API response
|
|
117
|
+
#
|
|
118
|
+
# @param response [Faraday::Response] HTTP response
|
|
119
|
+
# @return [String] Translated text
|
|
120
|
+
# @raise [TranslationError] if parsing fails or no translation found
|
|
121
|
+
# @api private
|
|
122
|
+
#
|
|
123
|
+
def extract_translation(response)
|
|
124
|
+
parsed = JSON.parse(response.body)
|
|
125
|
+
translation = parsed.dig("candidates", 0, "content", "parts", 0, "text")
|
|
126
|
+
|
|
127
|
+
raise TranslationError, "No translation in response" if translation.nil? || translation.empty?
|
|
128
|
+
|
|
129
|
+
translation.strip
|
|
130
|
+
rescue JSON::ParserError => e
|
|
131
|
+
raise TranslationError.new(
|
|
132
|
+
"Failed to parse Gemini response",
|
|
133
|
+
context: { error: e.message, body: response.body }
|
|
134
|
+
)
|
|
73
135
|
end
|
|
74
136
|
end
|
|
75
137
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Rails integration
|
|
5
|
+
#
|
|
6
|
+
# Automatically loads Rake tasks when used in a Rails application.
|
|
7
|
+
#
|
|
8
|
+
# @example In a Rails app, tasks are available automatically:
|
|
9
|
+
# rake better_translate:translate
|
|
10
|
+
# rake better_translate:config:generate
|
|
11
|
+
#
|
|
12
|
+
class Railtie < Rails::Railtie
|
|
13
|
+
rake_tasks do
|
|
14
|
+
rake_file = File.expand_path("../tasks/better_translate.rake", __dir__)
|
|
15
|
+
load rake_file if rake_file
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|