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,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