i18n-tasks 1.0.13 → 1.0.15
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/README.md +125 -38
- data/config/locales/en.yml +14 -5
- data/config/locales/ru.yml +14 -5
- data/i18n-tasks.gemspec +4 -2
- data/lib/i18n/tasks/command/commands/data.rb +14 -0
- data/lib/i18n/tasks/command/commands/missing.rb +3 -1
- data/lib/i18n/tasks/command/option_parsers/enum.rb +4 -3
- data/lib/i18n/tasks/command/options/common.rb +1 -1
- data/lib/i18n/tasks/command/options/locales.rb +12 -3
- data/lib/i18n/tasks/configuration.rb +8 -2
- data/lib/i18n/tasks/data/file_system_base.rb +5 -0
- data/lib/i18n/tasks/data/router/isolating_router.rb +146 -0
- data/lib/i18n/tasks/data/tree/siblings.rb +2 -2
- data/lib/i18n/tasks/interpolations.rb +1 -1
- data/lib/i18n/tasks/key_pattern_matching.rb +4 -4
- data/lib/i18n/tasks/reports/terminal.rb +6 -0
- data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +26 -0
- data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +1 -1
- data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +55 -25
- data/lib/i18n/tasks/scanners/local_ruby_parser.rb +2 -2
- data/lib/i18n/tasks/scanners/pattern_scanner.rb +1 -1
- data/lib/i18n/tasks/scanners/prism_scanner.rb +83 -0
- data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +41 -0
- data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +334 -0
- data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +273 -0
- data/lib/i18n/tasks/scanners/relative_keys.rb +1 -1
- data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +5 -4
- data/lib/i18n/tasks/scanners/ruby_key_literals.rb +1 -1
- data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
- data/lib/i18n/tasks/split_key.rb +30 -47
- data/lib/i18n/tasks/translation.rb +4 -1
- data/lib/i18n/tasks/translators/base_translator.rb +11 -1
- data/lib/i18n/tasks/translators/deepl_translator.rb +32 -1
- data/lib/i18n/tasks/translators/google_translator.rb +35 -12
- data/lib/i18n/tasks/translators/openai_translator.rb +55 -23
- data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
- data/lib/i18n/tasks/translators/yandex_translator.rb +5 -1
- data/lib/i18n/tasks/used_keys.rb +1 -0
- data/lib/i18n/tasks/version.rb +1 -1
- data/lib/i18n/tasks.rb +1 -0
- data/templates/config/i18n-tasks.yml +26 -3
- metadata +33 -26
- data/lib/i18n/tasks/scanners/erb_ast_processor.rb +0 -74
data/lib/i18n/tasks/split_key.rb
CHANGED
@@ -5,6 +5,11 @@ module I18n
|
|
5
5
|
module SplitKey
|
6
6
|
module_function
|
7
7
|
|
8
|
+
PARENTHESIS_PAIRS = %w({} [] () <>).freeze
|
9
|
+
START_KEYS = PARENTHESIS_PAIRS.to_set { |pair| pair[0] }.freeze
|
10
|
+
END_KEYS = PARENTHESIS_PAIRS.to_h { |pair| [pair[0], pair[1]] }.freeze
|
11
|
+
private_constant :PARENTHESIS_PAIRS, :START_KEYS, :END_KEYS
|
12
|
+
|
8
13
|
# split a key by dots (.)
|
9
14
|
# dots inside braces or parenthesis are not split on
|
10
15
|
#
|
@@ -12,61 +17,39 @@ module I18n
|
|
12
17
|
# split_key 'a.#{b.c}' # => ['a', '#{b.c}']
|
13
18
|
# split_key 'a.b.c', 2 # => ['a', 'b.c']
|
14
19
|
def split_key(key, max = Float::INFINITY)
|
15
|
-
parts = []
|
16
|
-
pos = 0
|
17
20
|
return [key] if max == 1
|
18
21
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
22
|
+
parts = []
|
23
|
+
current_parenthesis_end_char = nil
|
24
|
+
part = ''
|
25
|
+
key.each_char.with_index do |char, index|
|
26
|
+
if current_parenthesis_end_char
|
27
|
+
part += char
|
28
|
+
current_parenthesis_end_char = nil if char == current_parenthesis_end_char
|
29
|
+
elsif START_KEYS.include?(char)
|
30
|
+
part += char
|
31
|
+
current_parenthesis_end_char = END_KEYS[char]
|
32
|
+
elsif char == '.'
|
33
|
+
parts << part
|
34
|
+
if parts.size + 1 == max
|
35
|
+
remaining = key[(index + 1)..]
|
36
|
+
parts << remaining unless remaining.empty?
|
37
|
+
return parts
|
38
|
+
end
|
39
|
+
part = ''
|
40
|
+
else
|
41
|
+
part += char
|
25
42
|
end
|
26
43
|
end
|
27
|
-
parts
|
28
|
-
end
|
29
|
-
|
30
|
-
def last_key_part(key)
|
31
|
-
last = nil
|
32
|
-
key_parts(key) { |part| last = part }
|
33
|
-
last
|
34
|
-
end
|
35
44
|
|
36
|
-
|
37
|
-
# dots inside braces or parenthesis are not split on
|
38
|
-
def key_parts(key, &block)
|
39
|
-
return enum_for(:key_parts, key) unless block
|
45
|
+
return parts if part.empty?
|
40
46
|
|
41
|
-
|
42
|
-
counts = PARENS_ZEROS # dup'd later if key contains parenthesis
|
43
|
-
delim = '.'
|
44
|
-
from = to = 0
|
45
|
-
key.each_char do |char|
|
46
|
-
if char == delim && PARENS_ZEROS == counts
|
47
|
-
block.yield key[from...to]
|
48
|
-
from = to = (to + 1)
|
49
|
-
else
|
50
|
-
nest_i, nest_inc = nesting[char]
|
51
|
-
if nest_i
|
52
|
-
counts = counts.dup if counts.frozen?
|
53
|
-
counts[nest_i] += nest_inc
|
54
|
-
end
|
55
|
-
to += 1
|
56
|
-
end
|
57
|
-
end
|
58
|
-
block.yield(key[from...to]) if from < to && to <= key.length
|
59
|
-
true
|
47
|
+
current_parenthesis_end_char ? parts.concat(part.split('.')) : parts << part
|
60
48
|
end
|
61
49
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
h[s[1].freeze] = [i, -1].freeze
|
66
|
-
end.freeze
|
67
|
-
PARENS_ZEROS = Array.new(PARENS.size, 0).freeze
|
68
|
-
private_constant :PARENS
|
69
|
-
private_constant :PARENS_ZEROS
|
50
|
+
def last_key_part(key)
|
51
|
+
split_key(key).last
|
52
|
+
end
|
70
53
|
end
|
71
54
|
end
|
72
55
|
end
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require 'i18n/tasks/translators/deepl_translator'
|
4
4
|
require 'i18n/tasks/translators/google_translator'
|
5
5
|
require 'i18n/tasks/translators/openai_translator'
|
6
|
+
require 'i18n/tasks/translators/watsonx_translator'
|
6
7
|
require 'i18n/tasks/translators/yandex_translator'
|
7
8
|
|
8
9
|
module I18n::Tasks
|
@@ -11,7 +12,7 @@ module I18n::Tasks
|
|
11
12
|
# @param [String] from locale
|
12
13
|
# @param [:deepl, :openai, :google, :yandex] backend
|
13
14
|
# @return [I18n::Tasks::Tree::Siblings] translated forest
|
14
|
-
def translate_forest(forest, from:, backend:
|
15
|
+
def translate_forest(forest, from:, backend:)
|
15
16
|
case backend
|
16
17
|
when :deepl
|
17
18
|
Translators::DeeplTranslator.new(self).translate_forest(forest, from)
|
@@ -19,6 +20,8 @@ module I18n::Tasks
|
|
19
20
|
Translators::GoogleTranslator.new(self).translate_forest(forest, from)
|
20
21
|
when :openai
|
21
22
|
Translators::OpenAiTranslator.new(self).translate_forest(forest, from)
|
23
|
+
when :watsonx
|
24
|
+
Translators::WatsonxTranslator.new(self).translate_forest(forest, from)
|
22
25
|
when :yandex
|
23
26
|
Translators::YandexTranslator.new(self).translate_forest(forest, from)
|
24
27
|
else
|
@@ -14,7 +14,11 @@ module I18n::Tasks
|
|
14
14
|
# @return [I18n::Tasks::Tree::Siblings] translated forest
|
15
15
|
def translate_forest(forest, from)
|
16
16
|
forest.inject @i18n_tasks.empty_forest do |result, root|
|
17
|
-
|
17
|
+
pairs = root.key_values(root: true)
|
18
|
+
|
19
|
+
@progress_bar = ProgressBar.create(total: pairs.flatten.size, format: '%a <%B> %e %c/%C (%p%%)')
|
20
|
+
|
21
|
+
translated = translate_pairs(pairs, to: root.key, from: from)
|
18
22
|
result.merge! Data::Tree::Siblings.from_flat_pairs(translated)
|
19
23
|
end
|
20
24
|
end
|
@@ -70,6 +74,9 @@ module I18n::Tasks
|
|
70
74
|
when Array
|
71
75
|
# dump recursively
|
72
76
|
value.map { |v| dump_value(v, opts) }
|
77
|
+
when Hash
|
78
|
+
# dump recursively
|
79
|
+
value.values.map { |v| dump_value(v, opts) }
|
73
80
|
when String
|
74
81
|
value = CGI.escapeHTML(value) if opts[:html_escape]
|
75
82
|
replace_interpolations value unless value.empty?
|
@@ -85,6 +92,9 @@ module I18n::Tasks
|
|
85
92
|
when Array
|
86
93
|
# implode array
|
87
94
|
untranslated.map { |from| parse_value(from, each_translated, opts) }
|
95
|
+
when Hash
|
96
|
+
# implode hash
|
97
|
+
untranslated.transform_values { |value| parse_value(value, each_translated, opts) }
|
88
98
|
when String
|
89
99
|
if untranslated.empty?
|
90
100
|
untranslated
|
@@ -24,12 +24,19 @@ module I18n::Tasks::Translators
|
|
24
24
|
def translate_values(list, from:, to:, **options)
|
25
25
|
results = []
|
26
26
|
list.each_slice(BATCH_SIZE) do |parts|
|
27
|
-
res = DeepL.translate(
|
27
|
+
res = DeepL.translate(
|
28
|
+
parts,
|
29
|
+
to_deepl_source_locale(from),
|
30
|
+
to_deepl_target_locale(to),
|
31
|
+
options_with_glossary(options, from, to)
|
32
|
+
)
|
28
33
|
if res.is_a?(DeepL::Resources::Text)
|
29
34
|
results << res.text
|
30
35
|
else
|
31
36
|
results += res.map(&:text)
|
32
37
|
end
|
38
|
+
|
39
|
+
@progress_bar.progress += parts.size
|
33
40
|
end
|
34
41
|
results
|
35
42
|
end
|
@@ -78,6 +85,9 @@ module I18n::Tasks::Translators
|
|
78
85
|
|
79
86
|
# Convert 'es-ES' to 'ES' but warn about locales requiring a specific variant
|
80
87
|
def to_deepl_target_locale(locale)
|
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
|
+
|
81
91
|
loc, sub = locale.to_s.split('-')
|
82
92
|
if SPECIFIC_TARGETS.include?(loc)
|
83
93
|
# Must see how the deepl api evolves, so this could be an error in the future
|
@@ -100,5 +110,26 @@ module I18n::Tasks::Translators
|
|
100
110
|
config.version = version unless version.blank?
|
101
111
|
end
|
102
112
|
end
|
113
|
+
|
114
|
+
def options_with_glossary(options, from, to)
|
115
|
+
glossary = find_glossary(from, to)
|
116
|
+
glossary ? { glossary_id: glossary.id }.merge(options) : options
|
117
|
+
end
|
118
|
+
|
119
|
+
def all_ready_glossaries
|
120
|
+
@all_ready_glossaries ||= DeepL.glossaries.list
|
121
|
+
end
|
122
|
+
|
123
|
+
def find_glossary(from, to)
|
124
|
+
config_glossary_ids = @i18n_tasks.translation_config[:deepl_glossary_ids]
|
125
|
+
return unless config_glossary_ids
|
126
|
+
|
127
|
+
all_ready_glossaries.find do |glossary|
|
128
|
+
glossary.ready \
|
129
|
+
&& glossary.source_lang == from \
|
130
|
+
&& glossary.target_lang == to \
|
131
|
+
&& config_glossary_ids.include?(glossary.id)
|
132
|
+
end
|
133
|
+
end
|
103
134
|
end
|
104
135
|
end
|
@@ -4,6 +4,7 @@ require 'i18n/tasks/translators/base_translator'
|
|
4
4
|
|
5
5
|
module I18n::Tasks::Translators
|
6
6
|
class GoogleTranslator < BaseTranslator
|
7
|
+
NEWLINE_PLACEHOLDER = '<br id=i18n />'
|
7
8
|
def initialize(*)
|
8
9
|
begin
|
9
10
|
require 'easy_translate'
|
@@ -16,14 +17,25 @@ module I18n::Tasks::Translators
|
|
16
17
|
protected
|
17
18
|
|
18
19
|
def translate_values(list, **options)
|
19
|
-
|
20
|
+
result = restore_newlines(
|
21
|
+
EasyTranslate.translate(
|
22
|
+
replace_newlines_with_placeholder(list, options[:html]),
|
23
|
+
options,
|
24
|
+
format: options[:html] ? :html : :text
|
25
|
+
),
|
26
|
+
options[:html]
|
27
|
+
)
|
28
|
+
|
29
|
+
@progress_bar.progress += result.size
|
30
|
+
|
31
|
+
result
|
20
32
|
end
|
21
33
|
|
22
34
|
def options_for_translate_values(from:, to:, **options)
|
23
35
|
options.merge(
|
24
36
|
api_key: api_key,
|
25
|
-
from:
|
26
|
-
to:
|
37
|
+
from: from,
|
38
|
+
to: to
|
27
39
|
)
|
28
40
|
end
|
29
41
|
|
@@ -41,15 +53,6 @@ module I18n::Tasks::Translators
|
|
41
53
|
|
42
54
|
private
|
43
55
|
|
44
|
-
SUPPORTED_LOCALES_WITH_REGION = %w[zh-CN zh-TW].freeze
|
45
|
-
|
46
|
-
# Convert 'es-ES' to 'es'
|
47
|
-
def to_google_translate_compatible_locale(locale)
|
48
|
-
return locale unless locale.include?('-') && !SUPPORTED_LOCALES_WITH_REGION.include?(locale)
|
49
|
-
|
50
|
-
locale.split('-', 2).first
|
51
|
-
end
|
52
|
-
|
53
56
|
def api_key
|
54
57
|
@api_key ||= begin
|
55
58
|
key = @i18n_tasks.translation_config[:google_translate_api_key]
|
@@ -65,5 +68,25 @@ module I18n::Tasks::Translators
|
|
65
68
|
key
|
66
69
|
end
|
67
70
|
end
|
71
|
+
|
72
|
+
def replace_newlines_with_placeholder(list, html)
|
73
|
+
return list unless html
|
74
|
+
|
75
|
+
list.map do |value|
|
76
|
+
value.gsub(/\n(\s*)/) do
|
77
|
+
"<Z__#{::Regexp.last_match(1)&.length || 0}>"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def restore_newlines(translations, html)
|
83
|
+
return translations unless html
|
84
|
+
|
85
|
+
translations.map do |translation|
|
86
|
+
translation.gsub(/<Z__(\d+)>/) do
|
87
|
+
"\n#{' ' * ::Regexp.last_match(1).to_i}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
68
91
|
end
|
69
92
|
end
|
@@ -1,11 +1,28 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'i18n/tasks/translators/base_translator'
|
4
|
+
require 'active_support/core_ext/string/filters'
|
4
5
|
|
5
6
|
module I18n::Tasks::Translators
|
6
7
|
class OpenAiTranslator < BaseTranslator
|
7
8
|
# max allowed texts per request
|
8
9
|
BATCH_SIZE = 50
|
10
|
+
DEFAULT_SYSTEM_PROMPT = <<~PROMPT.squish
|
11
|
+
You are a professional translator that translates content from the %{from} locale
|
12
|
+
to the %{to} locale in an i18n locale array.
|
13
|
+
|
14
|
+
The array has a structured format and contains multiple strings. Your task is to translate
|
15
|
+
each of these strings and create a new array with the translated strings.
|
16
|
+
|
17
|
+
HTML markups (enclosed in < and > characters) must not be changed under any circumstance.
|
18
|
+
Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
|
19
|
+
|
20
|
+
Keep in mind the context of all the strings for a more accurate translation.
|
21
|
+
It is CRITICAL you output only the result, without any additional information, code block syntax or comments.
|
22
|
+
PROMPT
|
23
|
+
JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT = <<~PROMPT.squish
|
24
|
+
Return the translations as a JSON object with a 'translations' array containing the translated strings.
|
25
|
+
PROMPT
|
9
26
|
|
10
27
|
def initialize(*)
|
11
28
|
begin
|
@@ -38,7 +55,7 @@ module I18n::Tasks::Translators
|
|
38
55
|
private
|
39
56
|
|
40
57
|
def translator
|
41
|
-
@translator ||= OpenAI::Client.new(access_token: api_key)
|
58
|
+
@translator ||= OpenAI::Client.new(access_token: api_key, log_errors: true)
|
42
59
|
end
|
43
60
|
|
44
61
|
def api_key
|
@@ -50,26 +67,56 @@ module I18n::Tasks::Translators
|
|
50
67
|
end
|
51
68
|
end
|
52
69
|
|
70
|
+
def model
|
71
|
+
@model ||= @i18n_tasks.translation_config[:openai_model].presence || 'gpt-4o-mini'
|
72
|
+
end
|
73
|
+
|
74
|
+
def system_prompt
|
75
|
+
@system_prompt ||=
|
76
|
+
(@i18n_tasks.translation_config[:openai_system_prompt].presence || DEFAULT_SYSTEM_PROMPT)
|
77
|
+
.concat("\n#{JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT}")
|
78
|
+
@system_prompt
|
79
|
+
end
|
80
|
+
|
53
81
|
def translate_values(list, from:, to:)
|
54
82
|
results = []
|
55
83
|
|
56
84
|
list.each_slice(BATCH_SIZE) do |batch|
|
57
85
|
translations = translate(batch, from, to)
|
86
|
+
result = JSON.parse(translations)
|
87
|
+
results << result
|
58
88
|
|
59
|
-
|
89
|
+
@progress_bar.progress += result.size
|
60
90
|
end
|
61
91
|
|
62
92
|
results.flatten
|
63
93
|
end
|
64
94
|
|
65
|
-
def translate(values, from, to)
|
66
|
-
|
95
|
+
def translate(values, from, to)
|
96
|
+
response = translator.chat(
|
97
|
+
parameters: {
|
98
|
+
model: model,
|
99
|
+
messages: build_messages(values, from, to),
|
100
|
+
temperature: 0.0,
|
101
|
+
response_format: { type: 'json_object' }
|
102
|
+
}
|
103
|
+
)
|
104
|
+
|
105
|
+
translations = response.dig('choices', 0, 'message', 'content')
|
106
|
+
error = response['error']
|
107
|
+
|
108
|
+
fail "AI error: #{error}" if error.present?
|
109
|
+
|
110
|
+
# Extract the array from the JSON object response
|
111
|
+
result = JSON.parse(translations)
|
112
|
+
result['translations'].to_json
|
113
|
+
end
|
114
|
+
|
115
|
+
def build_messages(values, from, to)
|
116
|
+
[
|
67
117
|
{
|
68
118
|
role: 'system',
|
69
|
-
content:
|
70
|
-
locale array. The array has a structured format and contains multiple strings. Your task is to translate
|
71
|
-
each of these strings and create a new array with the translated strings. Keep in mind the context of all
|
72
|
-
the strings for a more accurate translation.\n"
|
119
|
+
content: format(system_prompt, from: from, to: to)
|
73
120
|
},
|
74
121
|
{
|
75
122
|
role: 'user',
|
@@ -80,21 +127,6 @@ module I18n::Tasks::Translators
|
|
80
127
|
content: values.to_json
|
81
128
|
}
|
82
129
|
]
|
83
|
-
|
84
|
-
response = translator.chat(
|
85
|
-
parameters: {
|
86
|
-
model: 'gpt-3.5-turbo',
|
87
|
-
messages: messages,
|
88
|
-
temperature: 0.7
|
89
|
-
}
|
90
|
-
)
|
91
|
-
|
92
|
-
translations = response.dig('choices', 0, 'message', 'content')
|
93
|
-
error = response['error']
|
94
|
-
|
95
|
-
fail "AI error: #{error}" if error.present?
|
96
|
-
|
97
|
-
translations
|
98
130
|
end
|
99
131
|
end
|
100
132
|
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
|
@@ -16,7 +16,11 @@ module I18n::Tasks::Translators
|
|
16
16
|
protected
|
17
17
|
|
18
18
|
def translate_values(list, **options)
|
19
|
-
list.map { |item| translator.translate(item, options) }
|
19
|
+
result = list.map { |item| translator.translate(item, options) }
|
20
|
+
|
21
|
+
@progress_bar.progress += result.size
|
22
|
+
|
23
|
+
result
|
20
24
|
end
|
21
25
|
|
22
26
|
def options_for_translate_values(from:, to:, **options)
|
data/lib/i18n/tasks/used_keys.rb
CHANGED
@@ -4,6 +4,7 @@ require 'find'
|
|
4
4
|
require 'i18n/tasks/scanners/pattern_with_scope_scanner'
|
5
5
|
require 'i18n/tasks/scanners/ruby_ast_scanner'
|
6
6
|
require 'i18n/tasks/scanners/erb_ast_scanner'
|
7
|
+
require 'i18n/tasks/scanners/prism_scanner'
|
7
8
|
require 'i18n/tasks/scanners/scanner_multiplexer'
|
8
9
|
require 'i18n/tasks/scanners/files/caching_file_finder_provider'
|
9
10
|
require 'i18n/tasks/scanners/files/caching_file_reader'
|
data/lib/i18n/tasks/version.rb
CHANGED
data/lib/i18n/tasks.rb
CHANGED
@@ -13,7 +13,7 @@ data:
|
|
13
13
|
## Provide a custom adapter:
|
14
14
|
# adapter: I18n::Tasks::Data::FileSystem
|
15
15
|
|
16
|
-
# Locale files or `
|
16
|
+
# Locale files or `Dir.glob` patterns where translations are read from:
|
17
17
|
read:
|
18
18
|
## Default:
|
19
19
|
# - config/locales/%{locale}.yml
|
@@ -34,7 +34,7 @@ data:
|
|
34
34
|
## Example (replace %#= with %=):
|
35
35
|
# - "<%#= %x[bundle info vagrant --path].chomp %>/templates/locales/%{locale}.yml"
|
36
36
|
|
37
|
-
## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class.
|
37
|
+
## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, isolating_router, or a custom class.
|
38
38
|
# router: conservative_router
|
39
39
|
|
40
40
|
yaml:
|
@@ -92,9 +92,13 @@ search:
|
|
92
92
|
## - RailsModelMatcher
|
93
93
|
## Matches ActiveRecord translations like
|
94
94
|
## User.human_attribute_name(:email) and User.model_name.human
|
95
|
+
## - DefaultI18nSubjectMatcher
|
96
|
+
## Matches ActionMailer's default_i18n_subject method
|
95
97
|
##
|
96
98
|
## To implement your own, please see `I18n::Tasks::Scanners::AstMatchers::BaseMatcher`.
|
97
|
-
#
|
99
|
+
# ast_matchers:
|
100
|
+
# - 'I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher'
|
101
|
+
# - 'I18n::Tasks::Scanners::AstMatchers::DefaultI18nSubjectMatcher'
|
98
102
|
|
99
103
|
## Multiple scanners can be used. Their results are merged.
|
100
104
|
## The options specified above are passed down to each scanner. Per-scanner options can be specified as well.
|
@@ -110,9 +114,28 @@ search:
|
|
110
114
|
# deepl_api_key: "48E92789-57A3-466A-9959-1A1A1A1A1A1A"
|
111
115
|
# # deepl_host: "https://api.deepl.com"
|
112
116
|
# # deepl_version: "v2"
|
117
|
+
# # deepl_glossary_ids:
|
118
|
+
# # - f28106eb-0e06-489e-82c6-8215d6f95089
|
119
|
+
# # - 2c6415be-1852-4f54-9e1b-d800463496b4
|
113
120
|
# # add additional options to the DeepL.translate call: https://www.deepl.com/docs-api/translate-text/translate-text/
|
114
121
|
# deepl_options:
|
115
122
|
# formality: prefer_less
|
123
|
+
# # OpenAI
|
124
|
+
# openai_api_key: "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
125
|
+
# # openai_model: "gpt-4o-mini" # see https://platform.openai.com/docs/models
|
126
|
+
# # may contain `%{from}` and `%{to}`, which will be replaced by source and target locale codes, respectively (using `Kernel.format`)
|
127
|
+
# # openai_system_prompt: >-
|
128
|
+
# # You are a professional translator that translates content from the %{from} locale
|
129
|
+
# # to the %{to} locale in an i18n locale array.
|
130
|
+
# #
|
131
|
+
# # The array has a structured format and contains multiple strings. Your task is to translate
|
132
|
+
# # each of these strings and create a new array with the translated strings.
|
133
|
+
# #
|
134
|
+
# # HTML markups (enclosed in < and > characters) must not be changed under any circumstance.
|
135
|
+
# # Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
|
136
|
+
# #
|
137
|
+
# # Keep in mind the context of all the strings for a more accurate translation.
|
138
|
+
|
116
139
|
## Do not consider these keys missing:
|
117
140
|
# ignore_missing:
|
118
141
|
# - 'errors.messages.{accepted,blank,invalid,too_short,too_long}'
|