i18n-tasks 1.0.14 → 1.1.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +138 -39
  3. data/Rakefile +4 -4
  4. data/bin/i18n-tasks +3 -3
  5. data/config/locales/en.yml +17 -1
  6. data/config/locales/ru.yml +18 -1
  7. data/i18n-tasks.gemspec +28 -38
  8. data/lib/i18n/tasks/base_task.rb +19 -19
  9. data/lib/i18n/tasks/cli.rb +37 -30
  10. data/lib/i18n/tasks/command/collection.rb +4 -4
  11. data/lib/i18n/tasks/command/commander.rb +5 -5
  12. data/lib/i18n/tasks/command/commands/check_prism.rb +126 -0
  13. data/lib/i18n/tasks/command/commands/data.rb +33 -33
  14. data/lib/i18n/tasks/command/commands/eq_base.rb +3 -3
  15. data/lib/i18n/tasks/command/commands/health.rb +6 -5
  16. data/lib/i18n/tasks/command/commands/interpolations.rb +14 -3
  17. data/lib/i18n/tasks/command/commands/meta.rb +6 -6
  18. data/lib/i18n/tasks/command/commands/missing.rb +28 -26
  19. data/lib/i18n/tasks/command/commands/tree.rb +33 -33
  20. data/lib/i18n/tasks/command/commands/usages.rb +24 -24
  21. data/lib/i18n/tasks/command/dsl.rb +1 -1
  22. data/lib/i18n/tasks/command/option_parsers/enum.rb +8 -7
  23. data/lib/i18n/tasks/command/option_parsers/locale.rb +4 -4
  24. data/lib/i18n/tasks/command/options/common.rb +16 -16
  25. data/lib/i18n/tasks/command/options/data.rb +18 -18
  26. data/lib/i18n/tasks/command/options/locales.rb +33 -24
  27. data/lib/i18n/tasks/commands.rb +14 -12
  28. data/lib/i18n/tasks/concurrent/cache.rb +1 -1
  29. data/lib/i18n/tasks/concurrent/cached_value.rb +1 -1
  30. data/lib/i18n/tasks/configuration.rb +26 -20
  31. data/lib/i18n/tasks/console_context.rb +11 -11
  32. data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
  33. data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +5 -5
  34. data/lib/i18n/tasks/data/file_formats.rb +3 -3
  35. data/lib/i18n/tasks/data/file_system.rb +5 -5
  36. data/lib/i18n/tasks/data/file_system_base.rb +26 -26
  37. data/lib/i18n/tasks/data/language_names.rb +202 -0
  38. data/lib/i18n/tasks/data/router/conservative_router.rb +3 -3
  39. data/lib/i18n/tasks/data/router/isolating_router.rb +19 -19
  40. data/lib/i18n/tasks/data/router/pattern_router.rb +5 -5
  41. data/lib/i18n/tasks/data/tree/node.rb +27 -27
  42. data/lib/i18n/tasks/data/tree/nodes.rb +10 -10
  43. data/lib/i18n/tasks/data/tree/siblings.rb +20 -20
  44. data/lib/i18n/tasks/data/tree/traversal.rb +5 -5
  45. data/lib/i18n/tasks/data.rb +4 -4
  46. data/lib/i18n/tasks/html_keys.rb +2 -2
  47. data/lib/i18n/tasks/ignore_keys.rb +9 -9
  48. data/lib/i18n/tasks/interpolations.rb +21 -1
  49. data/lib/i18n/tasks/key_pattern_matching.rb +8 -8
  50. data/lib/i18n/tasks/logging.rb +2 -1
  51. data/lib/i18n/tasks/missing_keys.rb +24 -8
  52. data/lib/i18n/tasks/plural_keys.rb +6 -4
  53. data/lib/i18n/tasks/references.rb +4 -4
  54. data/lib/i18n/tasks/reports/base.rb +18 -14
  55. data/lib/i18n/tasks/reports/terminal.rb +64 -47
  56. data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +3 -3
  57. data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +3 -3
  58. data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +10 -10
  59. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +2 -2
  60. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +69 -10
  61. data/lib/i18n/tasks/scanners/file_scanner.rb +5 -5
  62. data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +3 -3
  63. data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +3 -3
  64. data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +2 -2
  65. data/lib/i18n/tasks/scanners/files/file_finder.rb +8 -8
  66. data/lib/i18n/tasks/scanners/files/file_reader.rb +1 -1
  67. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +9 -9
  68. data/lib/i18n/tasks/scanners/occurrence_from_position.rb +1 -1
  69. data/lib/i18n/tasks/scanners/pattern_mapper.rb +7 -7
  70. data/lib/i18n/tasks/scanners/pattern_scanner.rb +20 -20
  71. data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +8 -8
  72. data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +48 -0
  73. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +374 -0
  74. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +337 -0
  75. data/lib/i18n/tasks/scanners/relative_keys.rb +8 -8
  76. data/lib/i18n/tasks/scanners/results/key_occurrences.rb +3 -3
  77. data/lib/i18n/tasks/scanners/results/occurrence.rb +14 -10
  78. data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +1 -1
  79. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +6 -6
  80. data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
  81. data/lib/i18n/tasks/scanners/ruby_scanner.rb +225 -0
  82. data/lib/i18n/tasks/scanners/scanner.rb +2 -2
  83. data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +1 -1
  84. data/lib/i18n/tasks/split_key.rb +4 -4
  85. data/lib/i18n/tasks/stats.rb +3 -3
  86. data/lib/i18n/tasks/translation.rb +8 -5
  87. data/lib/i18n/tasks/translators/base_translator.rb +43 -13
  88. data/lib/i18n/tasks/translators/deepl_translator.rb +22 -14
  89. data/lib/i18n/tasks/translators/google_translator.rb +178 -26
  90. data/lib/i18n/tasks/translators/openai_translator.rb +56 -31
  91. data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
  92. data/lib/i18n/tasks/translators/yandex_translator.rb +13 -9
  93. data/lib/i18n/tasks/unused_keys.rb +1 -1
  94. data/lib/i18n/tasks/used_keys.rb +32 -32
  95. data/lib/i18n/tasks/version.rb +1 -1
  96. data/lib/i18n/tasks.rb +17 -16
  97. data/templates/config/i18n-tasks.yml +14 -2
  98. data/templates/minitest/i18n_test.rb +3 -3
  99. data/templates/rspec/i18n_spec.rb +7 -7
  100. metadata +38 -172
  101. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +0 -145
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/translators/base_translator'
3
+ require "i18n/tasks/translators/base_translator"
4
4
 
5
5
  module I18n::Tasks::Translators
6
6
  class DeeplTranslator < BaseTranslator
@@ -11,7 +11,7 @@ module I18n::Tasks::Translators
11
11
 
12
12
  def initialize(*)
13
13
  begin
14
- require 'deepl'
14
+ require "deepl"
15
15
  rescue LoadError
16
16
  raise ::I18n::Tasks::CommandError, "Add gem 'deepl-rb' to your Gemfile to use this command"
17
17
  end
@@ -35,6 +35,8 @@ module I18n::Tasks::Translators
35
35
  else
36
36
  results += res.map(&:text)
37
37
  end
38
+
39
+ @progress_bar.progress += parts.size
38
40
  end
39
41
  results
40
42
  end
@@ -42,15 +44,15 @@ module I18n::Tasks::Translators
42
44
  def options_for_translate_values(**options)
43
45
  extra_options = @i18n_tasks.translation_config[:deepl_options]&.symbolize_keys || {}
44
46
 
45
- extra_options.merge({ ignore_tags: %w[i18n] }).merge(options)
47
+ extra_options.merge({ignore_tags: %w[i18n]}).merge(options)
46
48
  end
47
49
 
48
50
  def options_for_html
49
- { tag_handling: 'xml' }
51
+ {tag_handling: "xml"}
50
52
  end
51
53
 
52
54
  def options_for_plain
53
- { preserve_formatting: true, tag_handling: 'xml', html_escape: true }
55
+ {preserve_formatting: true, tag_handling: "xml", html_escape: true}
54
56
  end
55
57
 
56
58
  # @param [String] value
@@ -65,28 +67,31 @@ module I18n::Tasks::Translators
65
67
  def restore_interpolations(untranslated, translated)
66
68
  return translated if untranslated !~ INTERPOLATION_KEY_RE
67
69
 
68
- translated.gsub(%r{</?i18n>}, '')
69
- rescue StandardError => e
70
+ translated.gsub(%r{</?i18n>}, "")
71
+ rescue => e
70
72
  raise_interpolation_error(untranslated, translated, e)
71
73
  end
72
74
 
73
75
  def no_results_error_message
74
- I18n.t('i18n_tasks.deepl_translate.errors.no_results')
76
+ I18n.t("i18n_tasks.deepl_translate.errors.no_results")
75
77
  end
76
78
 
77
79
  private
78
80
 
79
81
  # Convert 'es-ES' to 'ES', en-us to EN
80
82
  def to_deepl_source_locale(locale)
81
- locale.to_s.split('-', 2).first.upcase
83
+ locale.to_s.split("-", 2).first.upcase
82
84
  end
83
85
 
84
86
  # Convert 'es-ES' to 'ES' but warn about locales requiring a specific variant
85
87
  def to_deepl_target_locale(locale)
86
- loc, sub = locale.to_s.split('-')
88
+ locale_aliases = @i18n_tasks.translation_config[:deepl_locale_aliases]
89
+ locale = locale_aliases[locale.to_s.downcase] || locale if locale_aliases.is_a?(Hash)
90
+
91
+ loc, sub = locale.to_s.split("-")
87
92
  if SPECIFIC_TARGETS.include?(loc)
88
93
  # Must see how the deepl api evolves, so this could be an error in the future
89
- warn_deprecated I18n.t('i18n_tasks.deepl_translate.errors.specific_target_missing') unless sub
94
+ warn_deprecated I18n.t("i18n_tasks.deepl_translate.errors.specific_target_missing") unless sub
90
95
  locale.to_s.upcase
91
96
  else
92
97
  loc.upcase
@@ -97,7 +102,7 @@ module I18n::Tasks::Translators
97
102
  api_key = @i18n_tasks.translation_config[:deepl_api_key]
98
103
  host = @i18n_tasks.translation_config[:deepl_host]
99
104
  version = @i18n_tasks.translation_config[:deepl_version]
100
- fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.deepl_translate.errors.no_api_key') if api_key.blank?
105
+ fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.deepl_translate.errors.no_api_key") if api_key.blank?
101
106
 
102
107
  DeepL.configure do |config|
103
108
  config.auth_key = api_key
@@ -106,9 +111,12 @@ module I18n::Tasks::Translators
106
111
  end
107
112
  end
108
113
 
114
+ # The Free API endpoint doesn’t expose glossaries via DeepL.glossaries.list,
115
+ # so if no API-backed glossary is found, fall back to the first ID from i18n-config.yml.
109
116
  def options_with_glossary(options, from, to)
110
- glossary = find_glossary(from, to)
111
- glossary ? { glossary_id: glossary.id }.merge(options) : options
117
+ configured = @i18n_tasks.translation_config[:deepl_glossary_ids]
118
+ gid = find_glossary(from, to)&.id || configured&.first
119
+ gid ? {glossary_id: gid}.merge(options) : options
112
120
  end
113
121
 
114
122
  def all_ready_glossaries
@@ -1,30 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/translators/base_translator'
3
+ require "cgi"
4
+ require "net/http"
5
+ require "uri"
6
+ require "json"
7
+ require "i18n/tasks/translators/base_translator"
8
+
9
+ # https://cloud.google.com/translate/docs/reference/rest/v2/translate
4
10
 
5
11
  module I18n::Tasks::Translators
6
12
  class GoogleTranslator < BaseTranslator
7
- NEWLINE_PLACEHOLDER = '<br id=i18n />'
13
+ API_ENDPOINT = "https://translation.googleapis.com/language/translate/v2"
14
+ MAX_TEXTS_PER_REQUEST = 128
15
+ MAX_CHARS_PER_REQUEST = 30_000
16
+
17
+ class Error < StandardError; end
18
+
19
+ class RateLimitError < Error; end
20
+
21
+ class QuotaExceededError < Error; end
22
+
8
23
  def initialize(*)
9
- begin
10
- require 'easy_translate'
11
- rescue LoadError
12
- raise ::I18n::Tasks::CommandError, "Add gem 'easy_translate' to your Gemfile to use this command"
13
- end
14
24
  super
15
25
  end
16
26
 
17
27
  protected
18
28
 
19
29
  def translate_values(list, **options)
20
- restore_newlines(
21
- EasyTranslate.translate(
22
- replace_newlines_with_placeholder(list, options[:html]),
23
- options,
24
- format: :text
30
+ html = options[:html].present?
31
+ result = restore_newlines(
32
+ translate(
33
+ texts: replace_newlines_with_placeholder(list),
34
+ api_key: api_key,
35
+ to: options[:to],
36
+ from: options[:from],
37
+ html:
25
38
  ),
26
- options[:html]
39
+ html:
27
40
  )
41
+
42
+ @progress_bar.progress += result.size
43
+
44
+ result
28
45
  end
29
46
 
30
47
  def options_for_translate_values(from:, to:, **options)
@@ -36,15 +53,15 @@ module I18n::Tasks::Translators
36
53
  end
37
54
 
38
55
  def options_for_html
39
- { html: true }
56
+ {html: true}
40
57
  end
41
58
 
42
59
  def options_for_plain
43
- { format: 'text' }
60
+ {format: "text"}
44
61
  end
45
62
 
46
63
  def no_results_error_message
47
- I18n.t('i18n_tasks.google_translate.errors.no_results')
64
+ I18n.t("i18n_tasks.google_translate.errors.no_results")
48
65
  end
49
66
 
50
67
  private
@@ -55,29 +72,164 @@ module I18n::Tasks::Translators
55
72
  # fallback with deprecation warning
56
73
  if @i18n_tasks.translation_config[:api_key]
57
74
  warn_deprecated(
58
- 'Please rename Google Translate API Key from `api_key` to `google_translate_api_key`.'
75
+ "Please rename Google Translate API Key from `api_key` to `google_translate_api_key`."
59
76
  )
60
77
  key ||= translation_config[:api_key]
61
78
  end
62
- fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.google_translate.errors.no_api_key') if key.blank?
79
+ fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.google_translate.errors.no_api_key") if key.blank?
63
80
 
64
81
  key
65
82
  end
66
83
  end
67
84
 
68
- def replace_newlines_with_placeholder(list, html)
69
- return list unless html
70
-
85
+ def replace_newlines_with_placeholder(list)
71
86
  list.map do |value|
72
- value.gsub("\n", NEWLINE_PLACEHOLDER)
87
+ value.gsub(/\n(\s*)/) do
88
+ "<Z__#{::Regexp.last_match(1)&.length || 0}>"
89
+ end
90
+ end
91
+ end
92
+
93
+ def restore_newlines(translations, html:)
94
+ restored = translations.map do |translation|
95
+ translation.gsub(/<Z__(\d+)>/) do
96
+ "\n#{" " * ::Regexp.last_match(1).to_i}"
97
+ end
98
+ end
99
+
100
+ # Need to unescape if translating HTML content
101
+ html ? restored.map { |t| CGI.unescapeHTML(t) } : restored
102
+ end
103
+
104
+ # @param texts [Array<String>] texts to translate
105
+ # @param api_key [String] Google Translate API key (required)
106
+ # @param to [String] target language code (required)
107
+ # @param format [Symbol] :text or :html (required)
108
+ # @param from [String] source language code
109
+ # @return [Array<String>] translated texts
110
+ def translate(texts:, api_key:, to:, html:, from: nil)
111
+ texts = Array(texts)
112
+
113
+ return [] if texts.empty?
114
+
115
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
116
+
117
+ raise ArgumentError, "target language (:to) is required" if to.nil? || to.empty?
118
+
119
+ # Split into batches if needed
120
+ batches = batch_texts(texts)
121
+
122
+ batches.flat_map do |batch|
123
+ translate_batch(texts: batch, api_key:, to:, from:, html:)
124
+ end
125
+ end
126
+
127
+ def batch_texts(texts)
128
+ batches = []
129
+ current_batch = []
130
+ current_chars = 0
131
+
132
+ texts.each do |text|
133
+ text_length = text.to_s.length
134
+
135
+ # If adding this text would exceed limits, start new batch
136
+ if current_batch.size >= MAX_TEXTS_PER_REQUEST ||
137
+ (current_chars + text_length > MAX_CHARS_PER_REQUEST && current_batch.any?)
138
+ batches << current_batch
139
+ current_batch = []
140
+ current_chars = 0
141
+ end
142
+
143
+ current_batch << text
144
+ current_chars += text_length
145
+ end
146
+
147
+ batches << current_batch if current_batch.any?
148
+ batches
149
+ end
150
+
151
+ def translate_batch(texts:, api_key:, to:, from:, html:)
152
+ uri = URI(API_ENDPOINT)
153
+ # Provide API key via query string as expected by v2 API
154
+ uri.query = URI.encode_www_form(key: api_key)
155
+
156
+ body = {
157
+ q: texts,
158
+ target: to,
159
+ format: html ? "html" : "text"
160
+ }
161
+
162
+ # Add source language if specified
163
+ if from
164
+ body[:source] = from
73
165
  end
166
+
167
+ response = make_request(uri, body)
168
+ parse_response(response, texts.size)
74
169
  end
75
170
 
76
- def restore_newlines(translations, html)
77
- return translations unless html
171
+ def make_request(uri, body)
172
+ http = Net::HTTP.new(uri.host, uri.port)
173
+ http.use_ssl = true
174
+ http.read_timeout = 60
175
+ http.open_timeout = 30
176
+
177
+ request = Net::HTTP::Post.new(uri.request_uri)
178
+ request["Content-Type"] = "application/json"
179
+ request["Accept"] = "application/json"
180
+ request["User-Agent"] = "i18n-tasks/GoogleTranslateApi"
181
+ request.body = JSON.generate(body)
182
+
183
+ response = http.request(request)
184
+
185
+ handle_error_response(response) unless response.is_a?(Net::HTTPSuccess)
186
+ response
187
+ end
188
+
189
+ def parse_response(response, expected_count)
190
+ data = JSON.parse(response.body)
191
+
192
+ unless data["data"] && data["data"]["translations"]
193
+ raise Error, "Unexpected API response format"
194
+ end
195
+
196
+ translations = data["data"]["translations"].map do |translation|
197
+ translation["translatedText"]
198
+ end
199
+
200
+ if translations.size != expected_count
201
+ raise Error, "Expected #{expected_count} translations, got #{translations.size}"
202
+ end
203
+
204
+ translations
205
+ rescue JSON::ParserError => e
206
+ raise Error, "Failed to parse API response: #{e.message}"
207
+ end
208
+
209
+ def handle_error_response(response)
210
+ begin
211
+ error_data = JSON.parse(response.body)
212
+ error_message = error_data.dig("error", "message") || "Unknown error"
213
+ error_code = error_data.dig("error", "code")
214
+ rescue JSON::ParserError
215
+ error_message = response.body
216
+ error_code = response.code
217
+ end
78
218
 
79
- translations.map do |translation|
80
- translation.gsub("#{NEWLINE_PLACEHOLDER} ", "\n")
219
+ status = response.code.to_i
220
+ diagnostic = "(status=#{status}, code=#{error_code})"
221
+ case status
222
+ when 400
223
+ raise Error, "Bad request #{diagnostic}: #{error_message}"
224
+ when 401, 403
225
+ raise Error, "Authentication/authorization failed #{diagnostic}: #{error_message}. " \
226
+ "Ensure the Cloud Translation API v2 is enabled, billing is active, and the key is unrestricted for this API."
227
+ when 429
228
+ raise RateLimitError, "Rate limit exceeded #{diagnostic}: #{error_message}"
229
+ when 503
230
+ raise QuotaExceededError, "Quota exceeded #{diagnostic}: #{error_message}"
231
+ else
232
+ raise Error, "API error #{diagnostic}: #{error_message}"
81
233
  end
82
234
  end
83
235
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/translators/base_translator'
4
- require 'active_support/core_ext/string/filters'
3
+ require "i18n/tasks/translators/base_translator"
4
+ require "active_support/core_ext/string/filters"
5
5
 
6
6
  module I18n::Tasks::Translators
7
7
  class OpenAiTranslator < BaseTranslator
8
+ include ::I18n::Tasks::Data::LanguageNames
9
+
8
10
  # max allowed texts per request
9
11
  BATCH_SIZE = 50
10
12
  DEFAULT_SYSTEM_PROMPT = <<~PROMPT.squish
@@ -18,11 +20,15 @@ module I18n::Tasks::Translators
18
20
  Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
19
21
 
20
22
  Keep in mind the context of all the strings for a more accurate translation.
23
+ It is CRITICAL you output only the result, without any additional information, code block syntax or comments.
24
+ PROMPT
25
+ JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT = <<~PROMPT.squish
26
+ Return the translations as a JSON object with a 'translations' array containing the translated strings.
21
27
  PROMPT
22
28
 
23
29
  def initialize(*)
24
30
  begin
25
- require 'openai'
31
+ require "openai"
26
32
  rescue LoadError
27
33
  raise ::I18n::Tasks::CommandError, "Add gem 'ruby-openai' to your Gemfile to use this command"
28
34
  end
@@ -45,74 +51,93 @@ module I18n::Tasks::Translators
45
51
  end
46
52
 
47
53
  def no_results_error_message
48
- I18n.t('i18n_tasks.openai_translate.errors.no_results')
54
+ I18n.t("i18n_tasks.openai_translate.errors.no_results")
49
55
  end
50
56
 
51
57
  private
52
58
 
53
59
  def translator
54
- @translator ||= OpenAI::Client.new(access_token: api_key)
60
+ @translator ||= OpenAI::Client.new(access_token: api_key, log_errors: true)
55
61
  end
56
62
 
57
63
  def api_key
58
64
  @api_key ||= begin
59
65
  key = @i18n_tasks.translation_config[:openai_api_key]
60
- fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.openai_translate.errors.no_api_key') if key.blank?
66
+ fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.openai_translate.errors.no_api_key") if key.blank?
61
67
 
62
68
  key
63
69
  end
64
70
  end
65
71
 
66
72
  def model
67
- @model ||= @i18n_tasks.translation_config[:openai_model].presence || 'gpt-3.5-turbo'
73
+ @model ||= @i18n_tasks.translation_config[:openai_model].presence || "gpt-4o-mini"
74
+ end
75
+
76
+ def temperature
77
+ @temperature ||= @i18n_tasks.translation_config[:openai_temperature].presence || 0.0
78
+ end
79
+
80
+ def system_prompt(to_locale)
81
+ prompt = if locale_prompts[to_locale].present?
82
+ locale_prompts[to_locale]
83
+ else
84
+ @i18n_tasks.translation_config[:openai_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
85
+ end
86
+
87
+ prompt.concat("\n#{JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT}")
68
88
  end
69
89
 
70
- def system_prompt
71
- @system_prompt ||= @i18n_tasks.translation_config[:openai_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
90
+ def locale_prompts
91
+ @locale_prompts ||= @i18n_tasks.translation_config[:openai_locale_prompts] || {}
72
92
  end
73
93
 
74
94
  def translate_values(list, from:, to:)
75
95
  results = []
76
96
 
77
97
  list.each_slice(BATCH_SIZE) do |batch|
78
- translations = translate(batch, from, to)
98
+ result = translate(batch, from, to)
99
+ results << result
79
100
 
80
- results << JSON.parse(translations)
101
+ @progress_bar.progress += result.size
81
102
  end
82
103
 
83
104
  results.flatten
84
105
  end
85
106
 
86
107
  def translate(values, from, to)
87
- messages = [
88
- {
89
- role: 'system',
90
- content: format(system_prompt, from: from, to: to)
91
- },
92
- {
93
- role: 'user',
94
- content: "Translate this array: \n\n\n"
95
- },
96
- {
97
- role: 'user',
98
- content: values.to_json
99
- }
100
- ]
101
-
102
108
  response = translator.chat(
103
109
  parameters: {
104
110
  model: model,
105
- messages: messages,
106
- temperature: 0.0
111
+ messages: build_messages(values, from, to),
112
+ temperature: temperature,
113
+ response_format: {type: "json_object"}
107
114
  }
108
115
  )
109
116
 
110
- translations = response.dig('choices', 0, 'message', 'content')
111
- error = response['error']
117
+ translations = response.dig("choices", 0, "message", "content")
118
+ error = response["error"]
112
119
 
113
120
  fail "AI error: #{error}" if error.present?
114
121
 
115
- translations
122
+ # Extract the array from the JSON object response
123
+ JSON.parse(translations)["translations"]
124
+ end
125
+
126
+ def build_messages(values, from, to)
127
+ [
128
+ {
129
+ role: "system",
130
+ content: format(system_prompt(to), from: language_name(from), to: language_name(to))
131
+ },
132
+ {
133
+ role: "user",
134
+ content: "Translate this array: \n\n\n"
135
+ },
136
+ {
137
+ role: "user",
138
+ content: values.to_json
139
+ }
140
+ ]
116
141
  end
117
142
  end
118
143
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n/tasks/translators/base_translator"
4
+ require "active_support/core_ext/string/filters"
5
+
6
+ module I18n::Tasks::Translators
7
+ class WatsonxTranslator < BaseTranslator
8
+ # max allowed texts per request
9
+ BATCH_SIZE = 50
10
+ DEFAULT_SYSTEM_PROMPT = <<~PROMPT.squish
11
+ You are a helpful assistant that translates content from the %{from} locale
12
+ to the %{to} locale in an i18n locale array.
13
+ You always preserve the structure and formatting exactly as it is.
14
+
15
+ The array has a structured format and contains multiple strings. Your task is to translate
16
+ each of these strings and create a new array with the translated strings.
17
+
18
+ Reminder:
19
+ - Translate only the text, preserving the structure and formatting.
20
+ - Do not translate any URLs.
21
+ - Do not translate HTML tags like `<details>` and `<summary>`.
22
+ - HTML markups (enclosed in < and > characters) must not be changed under any circumstance.
23
+ - Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
24
+ - Output only the result, without any additional information or comments.
25
+ PROMPT
26
+
27
+ def options_for_translate_values(from:, to:, **options)
28
+ options.merge(
29
+ from: from,
30
+ to: to
31
+ )
32
+ end
33
+
34
+ def options_for_html
35
+ {}
36
+ end
37
+
38
+ def options_for_plain
39
+ {}
40
+ end
41
+
42
+ def no_results_error_message
43
+ I18n.t("i18n_tasks.watsonx_translate.errors.no_results")
44
+ end
45
+
46
+ private
47
+
48
+ def translator
49
+ @translator ||= WatsonxClient.new(key: api_key)
50
+ end
51
+
52
+ def api_key
53
+ @api_key ||= begin
54
+ key = @i18n_tasks.translation_config[:watsonx_api_key]
55
+ fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.watsonx_translate.errors.no_api_key") if key.blank?
56
+
57
+ key
58
+ end
59
+ end
60
+
61
+ def project_id
62
+ @project_id ||= begin
63
+ project_id = @i18n_tasks.translation_config[:watsonx_project_id]
64
+ if project_id.blank?
65
+ fail ::I18n::Tasks::CommandError,
66
+ I18n.t("i18n_tasks.watsonx_translate.errors.no_project_id")
67
+ end
68
+
69
+ project_id
70
+ end
71
+ end
72
+
73
+ def model
74
+ @model ||= @i18n_tasks.translation_config[:watsonx_model].presence || "meta-llama/llama-3-2-90b-vision-instruct"
75
+ end
76
+
77
+ def system_prompt
78
+ @system_prompt ||= @i18n_tasks.translation_config[:watsonx_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
79
+ end
80
+
81
+ def translate_values(list, from:, to:)
82
+ results = []
83
+
84
+ list.each_slice(BATCH_SIZE) do |batch|
85
+ translations = translate(batch, from, to)
86
+ result = JSON.parse(translations)
87
+ results << result
88
+
89
+ @progress_bar.progress += results.size
90
+ end
91
+
92
+ results.flatten
93
+ end
94
+
95
+ def translate(values, from, to)
96
+ prompt = [
97
+ "<|eot_id|><|start_header_id|>system<|end_header_id|>",
98
+ format(system_prompt, from: from, to: to),
99
+ "<|eot_id|><|start_header_id|>user<|end_header_id|>Translate this array:",
100
+ "<|eot_id|><|start_header_id|>user<|end_header_id|>#{values.to_json}",
101
+ "<|eot_id|><|start_header_id|>assistant<|end_header_id|>"
102
+ ].join
103
+
104
+ response = translator.generate_text(
105
+ model_id: model,
106
+ project_id: project_id,
107
+ input: prompt,
108
+ parameters: {
109
+ decoding_method: :greedy,
110
+ max_new_tokens: 2048,
111
+ repetition_penalty: 1
112
+ }
113
+ )
114
+ response.dig("results", 0, "generated_text")
115
+ end
116
+ end
117
+ end
118
+
119
+ class WatsonxClient
120
+ WATSONX_BASE_URL = "https://us-south.ml.cloud.ibm.com/ml/"
121
+ IBM_CLOUD_IAM_URL = "https://iam.cloud.ibm.com/identity/token"
122
+
123
+ def initialize(key:)
124
+ begin
125
+ require "faraday"
126
+ rescue LoadError
127
+ raise ::I18n::Tasks::CommandError, "Add gem 'faraday' to your Gemfile to use this command"
128
+ end
129
+
130
+ @http = Faraday.new(url: WATSONX_BASE_URL) do |conn|
131
+ conn.use Faraday::Response::RaiseError
132
+ conn.request :json
133
+ conn.response :json
134
+ conn.options.timeout = 600
135
+ conn.request :authorization, :Bearer, token(key)
136
+ end
137
+ end
138
+
139
+ def generate_text(**opts)
140
+ @http.post("v1/text/generation?version=2024-05-20", **opts).body
141
+ end
142
+
143
+ private
144
+
145
+ def token(key)
146
+ Faraday.new(url: IBM_CLOUD_IAM_URL) do |conn|
147
+ conn.use Faraday::Response::RaiseError
148
+ conn.response :json
149
+ conn.params = {
150
+ grant_type: "urn:ibm:params:oauth:grant-type:apikey",
151
+ apikey: key
152
+ }
153
+ end.post.body["access_token"]
154
+ end
155
+ end