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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +14 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/.yardopts +10 -0
  6. data/CHANGELOG.md +125 -114
  7. data/CLAUDE.md +385 -0
  8. data/README.md +629 -244
  9. data/Rakefile +7 -1
  10. data/Steepfile +29 -0
  11. data/docs/implementation/00-overview.md +220 -0
  12. data/docs/implementation/01-setup_dependencies.md +668 -0
  13. data/docs/implementation/02-error_handling.md +65 -0
  14. data/docs/implementation/03-core_components.md +457 -0
  15. data/docs/implementation/03.5-variable_preservation.md +509 -0
  16. data/docs/implementation/04-provider_architecture.md +571 -0
  17. data/docs/implementation/05-translation_logic.md +1065 -0
  18. data/docs/implementation/06-main_module_api.md +122 -0
  19. data/docs/implementation/07-direct_translation_helpers.md +582 -0
  20. data/docs/implementation/08-rails_integration.md +323 -0
  21. data/docs/implementation/09-testing_suite.md +228 -0
  22. data/docs/implementation/10-documentation_examples.md +150 -0
  23. data/docs/implementation/11-quality_security.md +65 -0
  24. data/docs/implementation/12-cli_standalone.md +698 -0
  25. data/exe/better_translate +9 -0
  26. data/lib/better_translate/cache.rb +125 -0
  27. data/lib/better_translate/cli.rb +304 -0
  28. data/lib/better_translate/configuration.rb +201 -0
  29. data/lib/better_translate/direct_translator.rb +131 -0
  30. data/lib/better_translate/errors.rb +101 -0
  31. data/lib/better_translate/progress_tracker.rb +157 -0
  32. data/lib/better_translate/provider_factory.rb +45 -0
  33. data/lib/better_translate/providers/anthropic_provider.rb +154 -0
  34. data/lib/better_translate/providers/base_http_provider.rb +239 -0
  35. data/lib/better_translate/providers/chatgpt_provider.rb +138 -44
  36. data/lib/better_translate/providers/gemini_provider.rb +123 -61
  37. data/lib/better_translate/railtie.rb +18 -0
  38. data/lib/better_translate/rate_limiter.rb +90 -0
  39. data/lib/better_translate/strategies/base_strategy.rb +58 -0
  40. data/lib/better_translate/strategies/batch_strategy.rb +56 -0
  41. data/lib/better_translate/strategies/deep_strategy.rb +45 -0
  42. data/lib/better_translate/strategies/strategy_selector.rb +43 -0
  43. data/lib/better_translate/translator.rb +115 -284
  44. data/lib/better_translate/utils/hash_flattener.rb +104 -0
  45. data/lib/better_translate/validator.rb +105 -0
  46. data/lib/better_translate/variable_extractor.rb +259 -0
  47. data/lib/better_translate/version.rb +2 -9
  48. data/lib/better_translate/yaml_handler.rb +168 -0
  49. data/lib/better_translate.rb +97 -73
  50. data/lib/generators/better_translate/analyze/USAGE +12 -0
  51. data/lib/generators/better_translate/analyze/analyze_generator.rb +94 -0
  52. data/lib/generators/better_translate/install/USAGE +13 -0
  53. data/lib/generators/better_translate/install/install_generator.rb +71 -0
  54. data/lib/generators/better_translate/install/templates/README +20 -0
  55. data/lib/generators/better_translate/install/templates/initializer.rb.tt +47 -0
  56. data/lib/generators/better_translate/translate/USAGE +13 -0
  57. data/lib/generators/better_translate/translate/translate_generator.rb +114 -0
  58. data/lib/tasks/better_translate.rake +136 -0
  59. data/sig/better_translate/cache.rbs +28 -0
  60. data/sig/better_translate/cli.rbs +24 -0
  61. data/sig/better_translate/configuration.rbs +78 -0
  62. data/sig/better_translate/direct_translator.rbs +18 -0
  63. data/sig/better_translate/errors.rbs +46 -0
  64. data/sig/better_translate/progress_tracker.rbs +29 -0
  65. data/sig/better_translate/provider_factory.rbs +8 -0
  66. data/sig/better_translate/providers/anthropic_provider.rbs +27 -0
  67. data/sig/better_translate/providers/base_http_provider.rbs +44 -0
  68. data/sig/better_translate/providers/chatgpt_provider.rbs +25 -0
  69. data/sig/better_translate/providers/gemini_provider.rbs +22 -0
  70. data/sig/better_translate/railtie.rbs +7 -0
  71. data/sig/better_translate/rate_limiter.rbs +20 -0
  72. data/sig/better_translate/strategies/base_strategy.rbs +19 -0
  73. data/sig/better_translate/strategies/batch_strategy.rbs +13 -0
  74. data/sig/better_translate/strategies/deep_strategy.rbs +11 -0
  75. data/sig/better_translate/strategies/strategy_selector.rbs +10 -0
  76. data/sig/better_translate/translator.rbs +24 -0
  77. data/sig/better_translate/utils/hash_flattener.rbs +14 -0
  78. data/sig/better_translate/validator.rbs +14 -0
  79. data/sig/better_translate/variable_extractor.rbs +40 -0
  80. data/sig/better_translate/version.rbs +4 -0
  81. data/sig/better_translate/yaml_handler.rbs +29 -0
  82. data/sig/better_translate.rbs +32 -2
  83. data/sig/faraday.rbs +22 -0
  84. data/sig/generators/better_translate/analyze/analyze_generator.rbs +18 -0
  85. data/sig/generators/better_translate/install/install_generator.rbs +14 -0
  86. data/sig/generators/better_translate/translate/translate_generator.rbs +10 -0
  87. data/sig/optparse.rbs +9 -0
  88. data/sig/psych.rbs +5 -0
  89. data/sig/rails.rbs +34 -0
  90. metadata +89 -203
  91. data/lib/better_translate/helper.rb +0 -83
  92. data/lib/better_translate/providers/base_provider.rb +0 -102
  93. data/lib/better_translate/service.rb +0 -144
  94. data/lib/better_translate/similarity_analyzer.rb +0 -218
  95. data/lib/better_translate/utils.rb +0 -55
  96. data/lib/better_translate/writer.rb +0 -75
  97. data/lib/generators/better_translate/analyze_generator.rb +0 -57
  98. data/lib/generators/better_translate/install_generator.rb +0 -14
  99. data/lib/generators/better_translate/templates/better_translate.rb +0 -56
  100. 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
- # Translation provider that uses OpenAI's ChatGPT API to perform translations.
4
- # Implements the BaseProvider interface with ChatGPT-specific translation logic.
5
- # Uses the gpt-3.5-turbo model with a specialized prompt for accurate translations.
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
- # @example
8
- # provider = BetterTranslate::Providers::ChatgptProvider.new(ENV['OPENAI_API_KEY'])
9
- # translated_text = provider.translate("Hello world", "fr", "French")
10
- class ChatgptProvider < BaseProvider
11
- # Translates text using the OpenAI ChatGPT API.
12
- # Implements the provider-specific translation logic using OpenAI's ChatGPT API.
13
- # Sends a carefully crafted prompt to the API to ensure high-quality translations
14
- # without explanations or additional text.
15
- #
16
- # @param text [String] The text to translate
17
- # @param target_lang_code [String] The target language code (e.g., "fr")
18
- # @param target_lang_name [String] The target language name (e.g., "French")
19
- # @return [String] The translated text
20
- # @raise [StandardError] If the API request fails or returns an error
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
- uri = URI("https://api.openai.com/v1/chat/completions")
23
- headers = {
24
- "Content-Type" => "application/json",
25
- "Authorization" => "Bearer #{@api_key}"
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
- # Build the prompt to translate the text.
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: "gpt-3.5-turbo",
31
- messages: [
32
- { role: "system", content: "You are a professional translator. Translate the following text exactly from #{BetterTranslate.configuration.source_language} to #{target_lang_name}. Provide ONLY the direct translation without any explanations, alternatives, or additional text. Do not include the original text. Do not use markdown formatting. Do not add any prefixes or notes. Just return the plain translated text." },
33
- { role: "user", content: "#{text}" }
34
- ],
35
- temperature: 0.3
115
+ model: MODEL,
116
+ messages: messages,
117
+ temperature: TEMPERATURE
36
118
  }
37
119
 
38
- http = Net::HTTP.new(uri.host, uri.port)
39
- http.use_ssl = true
40
- request = Net::HTTP::Post.new(uri.path, headers)
41
- request.body = body.to_json
42
-
43
- response = http.request(request)
44
- if response.is_a?(Net::HTTPSuccess)
45
- json = JSON.parse(response.body)
46
- translated_text = json.dig("choices", 0, "message", "content")
47
- translated_text ? translated_text.strip : text
48
- else
49
- raise "Errore HTTP #{response.code}: #{response.body}"
50
- end
51
- rescue StandardError => e
52
- raise "Errore durante la traduzione con ChatGPT: #{e.message}"
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
- # Translation provider that uses Google's Gemini API to perform translations.
10
- # Implements the BaseProvider interface with Gemini-specific translation logic.
11
- # Uses the gemini-2.0-flash model with a specialized prompt for accurate translations.
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
- # @example
14
- # provider = BetterTranslate::Providers::GeminiProvider.new(ENV['GOOGLE_GEMINI_KEY'])
15
- # translated_text = provider.translate("Hello world", "fr", "French")
16
- class GeminiProvider < BaseProvider
17
- # The base URL for the Gemini API
18
- # @return [String] The API endpoint URL
19
- GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
20
-
21
- # Implements the provider-specific translation logic using Google's Gemini API.
22
- # Sends a carefully crafted prompt to the API to ensure high-quality translations
23
- # without explanations or additional text. Includes minimal text cleaning to handle
24
- # any formatting issues in the response.
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
- url = "#{GEMINI_API_URL}?key=#{@api_key}"
33
- uri = URI(url)
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
- parts: [{
39
- text: "Translate the following text to #{target_lang_name}. Provide ONLY the direct translation without any explanations, alternatives, or additional text. Do not include the original text. Do not use markdown formatting. Do not add any prefixes or notes. Just return the plain translated text:\n\n#{text}"
40
- }]
41
- }]
100
+ contents: [
101
+ {
102
+ parts: [
103
+ { text: prompt }
104
+ ]
105
+ }
106
+ ]
42
107
  }
43
108
 
44
- http = Net::HTTP.new(uri.host, uri.port)
45
- http.use_ssl = true
46
- request = Net::HTTP::Post.new(uri.path + "?" + uri.query, headers)
47
- request.body = body.to_json
48
-
49
- begin
50
- response = http.request(request)
51
-
52
- if response.is_a?(Net::HTTPSuccess)
53
- json = JSON.parse(response.body)
54
-
55
- if json["candidates"]&.any? && json["candidates"][0]["content"]["parts"]&.any?
56
- translated_text = json["candidates"][0]["content"]["parts"][0]["text"]
57
-
58
- # Pulizia minima del testo, dato che il prompt è già specifico
59
- cleaned_text = translated_text.strip
60
- .gsub(/[\*\`\n"]/, '') # Rimuovi markdown, newline e virgolette
61
- .gsub(/\s+/, ' ') # Riduci spazi multipli a uno singolo
62
-
63
- cleaned_text.empty? ? text : cleaned_text
64
- else
65
- raise "Risposta Gemini non valida: #{json}"
66
- end
67
- else
68
- raise "Errore HTTP #{response.code}: #{response.body}"
69
- end
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