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,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Direct text translator for non-YAML workflows
|
|
5
|
+
#
|
|
6
|
+
# Provides convenience methods for translating individual strings or batches
|
|
7
|
+
# without requiring YAML files. Useful for runtime translation needs.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# config = Configuration.new
|
|
11
|
+
# config.provider = :chatgpt
|
|
12
|
+
# config.openai_key = ENV['OPENAI_API_KEY']
|
|
13
|
+
# config.source_language = "en"
|
|
14
|
+
#
|
|
15
|
+
# translator = DirectTranslator.new(config)
|
|
16
|
+
# result = translator.translate("Hello", to: "it", language_name: "Italian")
|
|
17
|
+
# #=> "Ciao"
|
|
18
|
+
#
|
|
19
|
+
# @example Batch translation
|
|
20
|
+
# results = translator.translate_batch(
|
|
21
|
+
# ["Hello", "Goodbye"],
|
|
22
|
+
# to: "it",
|
|
23
|
+
# language_name: "Italian"
|
|
24
|
+
# )
|
|
25
|
+
# #=> ["Ciao", "Arrivederci"]
|
|
26
|
+
#
|
|
27
|
+
class DirectTranslator
|
|
28
|
+
# @return [Configuration] Configuration object
|
|
29
|
+
attr_reader :config
|
|
30
|
+
|
|
31
|
+
# Initialize direct translator
|
|
32
|
+
#
|
|
33
|
+
# @param config [Configuration] Configuration object (must be valid)
|
|
34
|
+
# @raise [ConfigurationError] if configuration is invalid
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# translator = DirectTranslator.new(config)
|
|
38
|
+
#
|
|
39
|
+
def initialize(config)
|
|
40
|
+
@config = config
|
|
41
|
+
# Validate provider is configured (minimal validation for DirectTranslator)
|
|
42
|
+
raise ConfigurationError, "Provider must be configured" unless config.provider
|
|
43
|
+
|
|
44
|
+
@provider = ProviderFactory.create(config.provider, config)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Translate a single text string
|
|
48
|
+
#
|
|
49
|
+
# @param text [String] Text to translate
|
|
50
|
+
# @param to [String, Symbol] Target language code (e.g., "it", :it)
|
|
51
|
+
# @param language_name [String] Full language name (e.g., "Italian")
|
|
52
|
+
#
|
|
53
|
+
# @return [String] Translated text
|
|
54
|
+
# @raise [ValidationError] if text or language_code is invalid
|
|
55
|
+
# @raise [TranslationError] if translation fails
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# translator.translate("Hello", to: "it", language_name: "Italian")
|
|
59
|
+
# #=> "Ciao"
|
|
60
|
+
#
|
|
61
|
+
def translate(text, to:, language_name:)
|
|
62
|
+
Validator.validate_text!(text)
|
|
63
|
+
target_lang_code = to.to_s
|
|
64
|
+
Validator.validate_language_code!(target_lang_code)
|
|
65
|
+
|
|
66
|
+
@provider.translate_text(text, target_lang_code, language_name)
|
|
67
|
+
rescue ValidationError
|
|
68
|
+
raise # Re-raise validation errors without wrapping
|
|
69
|
+
rescue ApiError, StandardError => e
|
|
70
|
+
raise TranslationError.new(
|
|
71
|
+
"Failed to translate text: #{e.message}",
|
|
72
|
+
context: { text: text, target_lang: target_lang_code, original_error: e }
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Translate multiple text strings
|
|
77
|
+
#
|
|
78
|
+
# @param texts [Array<String>] Array of texts to translate
|
|
79
|
+
# @param to [String, Symbol] Target language code
|
|
80
|
+
# @param language_name [String] Full language name
|
|
81
|
+
# @param skip_errors [Boolean] Continue on error, returning nil for failed translations (default: false)
|
|
82
|
+
#
|
|
83
|
+
# @return [Array<String, nil>] Array of translated texts (nil for errors if skip_errors: true)
|
|
84
|
+
# @raise [ArgumentError] if texts is not an Array
|
|
85
|
+
# @raise [ValidationError] if any text is invalid
|
|
86
|
+
# @raise [TranslationError] if translation fails (unless skip_errors: true)
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# translator.translate_batch(
|
|
90
|
+
# ["Hello", "Goodbye"],
|
|
91
|
+
# to: "it",
|
|
92
|
+
# language_name: "Italian"
|
|
93
|
+
# )
|
|
94
|
+
# #=> ["Ciao", "Arrivederci"]
|
|
95
|
+
#
|
|
96
|
+
# @example With error handling
|
|
97
|
+
# translator.translate_batch(
|
|
98
|
+
# ["Hello", "Goodbye"],
|
|
99
|
+
# to: "it",
|
|
100
|
+
# language_name: "Italian",
|
|
101
|
+
# skip_errors: true
|
|
102
|
+
# )
|
|
103
|
+
# #=> ["Ciao", nil] # If second translation fails
|
|
104
|
+
#
|
|
105
|
+
def translate_batch(texts, to:, language_name:, skip_errors: false)
|
|
106
|
+
raise ArgumentError, "texts must be an Array" unless texts.is_a?(Array)
|
|
107
|
+
|
|
108
|
+
return [] if texts.empty?
|
|
109
|
+
|
|
110
|
+
# Validate all texts first
|
|
111
|
+
texts.each { |text| Validator.validate_text!(text) }
|
|
112
|
+
|
|
113
|
+
target_lang_code = to.to_s
|
|
114
|
+
Validator.validate_language_code!(target_lang_code)
|
|
115
|
+
|
|
116
|
+
# Translate each text
|
|
117
|
+
texts.map do |text|
|
|
118
|
+
@provider.translate_text(text, target_lang_code, language_name)
|
|
119
|
+
rescue ApiError, StandardError => e
|
|
120
|
+
unless skip_errors
|
|
121
|
+
raise TranslationError.new(
|
|
122
|
+
"Failed to translate text: #{e.message}",
|
|
123
|
+
context: { text: text, target_lang: target_lang_code, original_error: e }
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Base error class for all BetterTranslate errors
|
|
5
|
+
#
|
|
6
|
+
# @abstract
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
# @return [Hash] Additional context about the error
|
|
9
|
+
attr_reader :context
|
|
10
|
+
|
|
11
|
+
# Initialize a new error with optional context
|
|
12
|
+
#
|
|
13
|
+
# @param message [String] The error message
|
|
14
|
+
# @param context [Hash] Additional context information
|
|
15
|
+
def initialize(message = nil, context: {})
|
|
16
|
+
@context = context
|
|
17
|
+
super(message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when configuration is invalid or incomplete
|
|
22
|
+
#
|
|
23
|
+
# @example Raising with context
|
|
24
|
+
# raise ConfigurationError.new(
|
|
25
|
+
# "Invalid provider",
|
|
26
|
+
# context: { provider: :invalid_name }
|
|
27
|
+
# )
|
|
28
|
+
class ConfigurationError < Error; end
|
|
29
|
+
|
|
30
|
+
# Raised when input validation fails
|
|
31
|
+
#
|
|
32
|
+
# @example File validation failure
|
|
33
|
+
# raise ValidationError.new(
|
|
34
|
+
# "File does not exist",
|
|
35
|
+
# context: { file_path: "/path/to/file.yml" }
|
|
36
|
+
# )
|
|
37
|
+
class ValidationError < Error; end
|
|
38
|
+
|
|
39
|
+
# Raised when translation fails
|
|
40
|
+
#
|
|
41
|
+
# @example Translation error
|
|
42
|
+
# raise TranslationError.new(
|
|
43
|
+
# "Failed to translate text",
|
|
44
|
+
# context: { text: "Hello", target_lang: "it" }
|
|
45
|
+
# )
|
|
46
|
+
class TranslationError < Error; end
|
|
47
|
+
|
|
48
|
+
# Raised when a provider encounters an error
|
|
49
|
+
#
|
|
50
|
+
# @example Provider initialization error
|
|
51
|
+
# raise ProviderError.new(
|
|
52
|
+
# "Provider not initialized",
|
|
53
|
+
# context: { provider: :chatgpt }
|
|
54
|
+
# )
|
|
55
|
+
class ProviderError < Error; end
|
|
56
|
+
|
|
57
|
+
# Raised when an API call fails
|
|
58
|
+
#
|
|
59
|
+
# @example API call failure
|
|
60
|
+
# raise ApiError.new(
|
|
61
|
+
# "API request failed",
|
|
62
|
+
# context: { status_code: 500, response: "Internal Server Error" }
|
|
63
|
+
# )
|
|
64
|
+
class ApiError < Error; end
|
|
65
|
+
|
|
66
|
+
# Raised when rate limit is exceeded
|
|
67
|
+
#
|
|
68
|
+
# @example Rate limit error
|
|
69
|
+
# raise RateLimitError.new(
|
|
70
|
+
# "Rate limit exceeded",
|
|
71
|
+
# context: { retry_after: 60 }
|
|
72
|
+
# )
|
|
73
|
+
class RateLimitError < ApiError; end
|
|
74
|
+
|
|
75
|
+
# Raised when file operations fail
|
|
76
|
+
#
|
|
77
|
+
# @example File read error
|
|
78
|
+
# raise FileError.new(
|
|
79
|
+
# "Cannot read file",
|
|
80
|
+
# context: { file_path: "config/locales/en.yml", error: "Permission denied" }
|
|
81
|
+
# )
|
|
82
|
+
class FileError < Error; end
|
|
83
|
+
|
|
84
|
+
# Raised when YAML parsing fails
|
|
85
|
+
#
|
|
86
|
+
# @example YAML syntax error
|
|
87
|
+
# raise YamlError.new(
|
|
88
|
+
# "Invalid YAML syntax",
|
|
89
|
+
# context: { file_path: "config/locales/en.yml", line: 5 }
|
|
90
|
+
# )
|
|
91
|
+
class YamlError < Error; end
|
|
92
|
+
|
|
93
|
+
# Raised when a provider is not found
|
|
94
|
+
#
|
|
95
|
+
# @example Provider not found
|
|
96
|
+
# raise ProviderNotFoundError.new(
|
|
97
|
+
# "Provider 'unknown' not found",
|
|
98
|
+
# context: { provider: :unknown, available: [:chatgpt, :gemini, :anthropic] }
|
|
99
|
+
# )
|
|
100
|
+
class ProviderNotFoundError < Error; end
|
|
101
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Tracks and displays translation progress
|
|
5
|
+
#
|
|
6
|
+
# Shows real-time progress updates with colored console output.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# tracker = ProgressTracker.new(enabled: true)
|
|
10
|
+
# tracker.update(language: "Italian", current_key: "greeting", progress: 50.0)
|
|
11
|
+
# tracker.complete("Italian", 100)
|
|
12
|
+
#
|
|
13
|
+
class ProgressTracker
|
|
14
|
+
# @return [Boolean] Whether to show progress
|
|
15
|
+
attr_reader :enabled
|
|
16
|
+
|
|
17
|
+
# Initialize progress tracker
|
|
18
|
+
#
|
|
19
|
+
# @param enabled [Boolean] Whether to show progress (default: true)
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# tracker = ProgressTracker.new(enabled: true)
|
|
23
|
+
#
|
|
24
|
+
def initialize(enabled: true)
|
|
25
|
+
@enabled = enabled
|
|
26
|
+
@start_time = Time.now
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Update progress
|
|
30
|
+
#
|
|
31
|
+
# @param language [String] Current language being translated
|
|
32
|
+
# @param current_key [String] Current translation key
|
|
33
|
+
# @param progress [Float] Progress percentage (0-100)
|
|
34
|
+
# @return [void]
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# tracker.update(language: "Italian", current_key: "nav.home", progress: 75.5)
|
|
38
|
+
#
|
|
39
|
+
def update(language:, current_key:, progress:)
|
|
40
|
+
return unless enabled
|
|
41
|
+
|
|
42
|
+
elapsed = Time.now - @start_time
|
|
43
|
+
estimated_total = progress.positive? ? elapsed / (progress / 100.0) : 0
|
|
44
|
+
remaining = estimated_total - elapsed
|
|
45
|
+
|
|
46
|
+
message = format(
|
|
47
|
+
"\r[BetterTranslate] %s | %s | %.1f%% | Elapsed: %s | Remaining: ~%s",
|
|
48
|
+
colorize(language, :cyan),
|
|
49
|
+
truncate(current_key, 40),
|
|
50
|
+
progress,
|
|
51
|
+
format_time(elapsed),
|
|
52
|
+
format_time(remaining)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
print message
|
|
56
|
+
$stdout.flush
|
|
57
|
+
|
|
58
|
+
puts "" if progress >= 100.0 # New line when complete
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Mark translation as complete for a language
|
|
62
|
+
#
|
|
63
|
+
# @param language [String] Language name
|
|
64
|
+
# @param total_strings [Integer] Total number of strings translated
|
|
65
|
+
# @return [void]
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# tracker.complete("Italian", 150)
|
|
69
|
+
#
|
|
70
|
+
def complete(language, total_strings)
|
|
71
|
+
return unless enabled
|
|
72
|
+
|
|
73
|
+
elapsed = Time.now - @start_time
|
|
74
|
+
puts colorize("✓ #{language}: #{total_strings} strings translated in #{format_time(elapsed)}", :green)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Display an error
|
|
78
|
+
#
|
|
79
|
+
# @param language [String] Language name
|
|
80
|
+
# @param error [StandardError] The error that occurred
|
|
81
|
+
# @return [void]
|
|
82
|
+
#
|
|
83
|
+
# @example
|
|
84
|
+
# tracker.error("Italian", StandardError.new("API error"))
|
|
85
|
+
#
|
|
86
|
+
def error(language, error)
|
|
87
|
+
return unless enabled
|
|
88
|
+
|
|
89
|
+
puts colorize("✗ #{language}: #{error.message}", :red)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Reset the progress tracker
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# tracker.reset
|
|
98
|
+
#
|
|
99
|
+
def reset
|
|
100
|
+
@start_time = Time.now
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Format time in human-readable format
|
|
106
|
+
#
|
|
107
|
+
# @param seconds [Float] Seconds
|
|
108
|
+
# @return [String] Formatted time
|
|
109
|
+
# @api private
|
|
110
|
+
#
|
|
111
|
+
def format_time(seconds)
|
|
112
|
+
return "0s" if seconds <= 0
|
|
113
|
+
|
|
114
|
+
minutes = (seconds / 60).to_i
|
|
115
|
+
secs = (seconds % 60).to_i
|
|
116
|
+
|
|
117
|
+
if minutes.positive?
|
|
118
|
+
"#{minutes}m #{secs}s"
|
|
119
|
+
else
|
|
120
|
+
"#{secs}s"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Truncate text to max length
|
|
125
|
+
#
|
|
126
|
+
# @param text [String] Text to truncate
|
|
127
|
+
# @param max_length [Integer] Maximum length
|
|
128
|
+
# @return [String] Truncated text
|
|
129
|
+
# @api private
|
|
130
|
+
#
|
|
131
|
+
def truncate(text, max_length)
|
|
132
|
+
return text if text.length <= max_length
|
|
133
|
+
|
|
134
|
+
"#{text[0...(max_length - 3)]}..."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Colorize text for terminal output
|
|
138
|
+
#
|
|
139
|
+
# @param text [String] Text to colorize
|
|
140
|
+
# @param color [Symbol] Color name (:red, :green, :cyan)
|
|
141
|
+
# @return [String] Colorized text
|
|
142
|
+
# @api private
|
|
143
|
+
#
|
|
144
|
+
def colorize(text, color)
|
|
145
|
+
return text unless $stdout.tty?
|
|
146
|
+
|
|
147
|
+
colors = {
|
|
148
|
+
red: "\e[31m",
|
|
149
|
+
green: "\e[32m",
|
|
150
|
+
cyan: "\e[36m",
|
|
151
|
+
reset: "\e[0m"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
"#{colors[color]}#{text}#{colors[:reset]}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Factory for creating translation providers
|
|
5
|
+
#
|
|
6
|
+
# Creates the appropriate provider instance based on configuration.
|
|
7
|
+
#
|
|
8
|
+
# @example Creating a ChatGPT provider
|
|
9
|
+
# config = Configuration.new
|
|
10
|
+
# config.provider = :chatgpt
|
|
11
|
+
# config.openai_key = ENV['OPENAI_API_KEY']
|
|
12
|
+
# provider = ProviderFactory.create(:chatgpt, config)
|
|
13
|
+
# #=> #<BetterTranslate::Providers::ChatGPTProvider>
|
|
14
|
+
#
|
|
15
|
+
# @example Creating a Gemini provider
|
|
16
|
+
# config.provider = :gemini
|
|
17
|
+
# config.google_gemini_key = ENV['GOOGLE_GEMINI_KEY']
|
|
18
|
+
# provider = ProviderFactory.create(:gemini, config)
|
|
19
|
+
# #=> #<BetterTranslate::Providers::GeminiProvider>
|
|
20
|
+
#
|
|
21
|
+
class ProviderFactory
|
|
22
|
+
# Create a provider instance
|
|
23
|
+
#
|
|
24
|
+
# @param provider_name [Symbol] Provider name (:chatgpt, :gemini, :anthropic)
|
|
25
|
+
# @param config [Configuration] Configuration object
|
|
26
|
+
# @return [Providers::BaseHttpProvider] Provider instance
|
|
27
|
+
# @raise [ProviderNotFoundError] if provider is unknown
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# provider = ProviderFactory.create(:chatgpt, config)
|
|
31
|
+
#
|
|
32
|
+
def self.create(provider_name, config)
|
|
33
|
+
case provider_name
|
|
34
|
+
when :chatgpt
|
|
35
|
+
Providers::ChatGPTProvider.new(config)
|
|
36
|
+
when :gemini
|
|
37
|
+
Providers::GeminiProvider.new(config)
|
|
38
|
+
when :anthropic
|
|
39
|
+
Providers::AnthropicProvider.new(config)
|
|
40
|
+
else
|
|
41
|
+
raise ProviderNotFoundError, "Unknown provider: #{provider_name}. Supported: :chatgpt, :gemini, :anthropic"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
module Providers
|
|
5
|
+
# Anthropic Claude translation provider
|
|
6
|
+
#
|
|
7
|
+
# Uses claude-3-5-sonnet-20241022 model for high-quality translations.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# config = Configuration.new
|
|
11
|
+
# config.anthropic_key = ENV['ANTHROPIC_API_KEY']
|
|
12
|
+
# provider = AnthropicProvider.new(config)
|
|
13
|
+
# result = provider.translate_text("Hello", "it", "Italian")
|
|
14
|
+
# #=> "Ciao"
|
|
15
|
+
#
|
|
16
|
+
class AnthropicProvider < BaseHttpProvider
|
|
17
|
+
# Anthropic API endpoint
|
|
18
|
+
API_URL = "https://api.anthropic.com/v1/messages"
|
|
19
|
+
|
|
20
|
+
# Model to use for translations
|
|
21
|
+
MODEL = "claude-3-5-sonnet-20241022"
|
|
22
|
+
|
|
23
|
+
# API version
|
|
24
|
+
API_VERSION = "2023-06-01"
|
|
25
|
+
|
|
26
|
+
# Translate a single text
|
|
27
|
+
#
|
|
28
|
+
# @param text [String] Text to translate
|
|
29
|
+
# @param target_lang_code [String] Target language code
|
|
30
|
+
# @param target_lang_name [String] Target language name
|
|
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
|
+
#
|
|
39
|
+
def translate_text(text, target_lang_code, target_lang_name)
|
|
40
|
+
Validator.validate_text!(text)
|
|
41
|
+
Validator.validate_language_code!(target_lang_code)
|
|
42
|
+
|
|
43
|
+
cache_key = build_cache_key(text, target_lang_code)
|
|
44
|
+
|
|
45
|
+
with_cache(cache_key) do
|
|
46
|
+
messages = build_messages(text, target_lang_name)
|
|
47
|
+
response = make_messages_request(messages)
|
|
48
|
+
extract_translation(response)
|
|
49
|
+
end
|
|
50
|
+
rescue ApiError => e
|
|
51
|
+
raise TranslationError.new(
|
|
52
|
+
"Failed to translate text with Anthropic: #{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 for Anthropic API
|
|
75
|
+
#
|
|
76
|
+
# @param text [String] Text to translate
|
|
77
|
+
# @param target_lang_name [String] Target language name
|
|
78
|
+
# @return [Hash] Messages hash
|
|
79
|
+
# @api private
|
|
80
|
+
#
|
|
81
|
+
def build_messages(text, target_lang_name)
|
|
82
|
+
system_message = build_system_message(target_lang_name)
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
system: system_message,
|
|
86
|
+
messages: [
|
|
87
|
+
{ role: "user", content: text }
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Build system message with optional context
|
|
93
|
+
#
|
|
94
|
+
# @param target_lang_name [String] Target language name
|
|
95
|
+
# @return [String] System message
|
|
96
|
+
# @api private
|
|
97
|
+
#
|
|
98
|
+
def build_system_message(target_lang_name)
|
|
99
|
+
base_message = "You are a professional translator. Translate the following text to #{target_lang_name}. " \
|
|
100
|
+
"Return ONLY the translated text, without any explanations or additional text."
|
|
101
|
+
|
|
102
|
+
if config.translation_context && !config.translation_context.empty?
|
|
103
|
+
base_message += "\n\nContext: #{config.translation_context}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
base_message
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Make messages request to Anthropic API
|
|
110
|
+
#
|
|
111
|
+
# @param message_data [Hash] Message data with system and messages
|
|
112
|
+
# @return [Faraday::Response] HTTP response
|
|
113
|
+
# @api private
|
|
114
|
+
#
|
|
115
|
+
def make_messages_request(message_data)
|
|
116
|
+
body = {
|
|
117
|
+
model: MODEL,
|
|
118
|
+
max_tokens: 1024,
|
|
119
|
+
system: message_data[:system],
|
|
120
|
+
messages: message_data[:messages]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
headers = {
|
|
124
|
+
"Content-Type" => "application/json",
|
|
125
|
+
"x-api-key" => config.anthropic_key || "",
|
|
126
|
+
"anthropic-version" => API_VERSION
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
make_request(:post, API_URL, body: body, headers: headers)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extract translation from API response
|
|
133
|
+
#
|
|
134
|
+
# @param response [Faraday::Response] HTTP response
|
|
135
|
+
# @return [String] Translated text
|
|
136
|
+
# @raise [TranslationError] if parsing fails or no translation found
|
|
137
|
+
# @api private
|
|
138
|
+
#
|
|
139
|
+
def extract_translation(response)
|
|
140
|
+
parsed = JSON.parse(response.body)
|
|
141
|
+
translation = parsed.dig("content", 0, "text")
|
|
142
|
+
|
|
143
|
+
raise TranslationError, "No translation in response" if translation.nil? || translation.empty?
|
|
144
|
+
|
|
145
|
+
translation.strip
|
|
146
|
+
rescue JSON::ParserError => e
|
|
147
|
+
raise TranslationError.new(
|
|
148
|
+
"Failed to parse Anthropic response",
|
|
149
|
+
context: { error: e.message, body: response.body }
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|