mistral_translator 0.3.2 → 0.5.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.
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "logger"
4
+ require_relative "retries"
4
5
  require_relative "security"
5
6
 
6
7
  module MistralTranslator
7
8
  class Summarizer
9
+ include Retries
10
+
8
11
  DEFAULT_MAX_WORDS = 250
9
- DEFAULT_RETRY_COUNT = 3
10
- DEFAULT_RETRY_DELAY = 2
12
+ DEFAULT_RETRY_COUNT = Retries::RETRY_COUNT
13
+ DEFAULT_RETRY_DELAY = Retries::RETRY_DELAY
11
14
 
12
15
  def initialize(client: nil)
13
16
  @client = client || Client.new
@@ -15,7 +18,7 @@ module MistralTranslator
15
18
  end
16
19
 
17
20
  # Résumé simple dans une langue donnée
18
- def summarize(text, language: "fr", max_words: DEFAULT_MAX_WORDS)
21
+ def summarize(text, language: "fr", max_words: DEFAULT_MAX_WORDS, context: nil, style: nil)
19
22
  log_debug("Starting summarize - language: #{language}, max_words: #{max_words}")
20
23
 
21
24
  # Validation basique des entrées
@@ -26,18 +29,14 @@ module MistralTranslator
26
29
  return "" if validated_text.empty?
27
30
 
28
31
  target_locale = LocaleHelper.validate_locale!(language)
29
- log_debug("Target locale validated: #{target_locale}")
30
-
31
32
  cleaned_text = clean_document_content(validated_text)
32
- log_debug("Text cleaned, length: #{cleaned_text&.length}")
33
33
 
34
- result = summarize_with_retry(cleaned_text, target_locale, max_words)
35
- log_debug("Summary completed successfully")
36
- result
34
+ prompt = PromptBuilder.summary_prompt(cleaned_text, max_words, target_locale, context: context, style: style)
35
+ request_summary(prompt, target_locale)
37
36
  end
38
37
 
39
38
  # Résumé avec traduction simultanée
40
- def summarize_and_translate(text, from:, to:, max_words: DEFAULT_MAX_WORDS)
39
+ def summarize_and_translate(text, from:, to:, max_words: DEFAULT_MAX_WORDS, context: nil, style: nil) # rubocop:disable Metrics/ParameterLists
41
40
  log_debug("Starting summarize_and_translate - from: #{from}, to: #{to}")
42
41
  validate_summarize_translate_inputs!(text, from, to, max_words)
43
42
 
@@ -47,143 +46,57 @@ module MistralTranslator
47
46
 
48
47
  # Si même langue, juste résumer
49
48
  if source_locale == target_locale
50
- log_debug("Same language detected, using simple summarize")
51
- return summarize(cleaned_text, language: target_locale, max_words: max_words)
49
+ return summarize(cleaned_text, language: target_locale, max_words: max_words, context: context, style: style)
52
50
  end
53
51
 
54
- # Sinon, créer un prompt qui fait les deux à la fois
55
- log_debug("Different languages, using combined summarize+translate")
56
- prompt = build_summary_translation_prompt(cleaned_text, source_locale, target_locale, max_words)
57
- raw_response = @client.complete(prompt)
58
-
59
- result = ResponseParser.parse_summary_response(raw_response)
60
- if result.nil? || result[:summary].nil? || result[:summary].empty?
61
- # Fallback: si le JSON est manquant ou vide, on tente un résumé simple puis traduction
62
- log_error(
63
- "Empty or invalid summary response for summarize_and_translate. Using fallback. " \
64
- "Raw length: #{raw_response.to_s.length}"
65
- )
66
- summary_only = summarize(cleaned_text, language: source_locale, max_words: max_words)
67
- begin
68
- return MistralTranslator.translate(summary_only, from: source_locale, to: target_locale)
69
- rescue EmptyTranslationError, InvalidResponseError, RateLimitError => e
70
- log_error("Translation fallback failed: #{e.class.name} - returning source summary")
71
- return summary_only
72
- end
73
- end
74
-
75
- result[:summary]
52
+ prompt = PromptBuilder.summary_and_translation_prompt(cleaned_text, source_locale, target_locale, max_words,
53
+ context: context, style: style)
54
+ request_summary(prompt, target_locale)
76
55
  end
77
56
 
78
57
  # Résumé en plusieurs langues
79
- def summarize_to_multiple(text, languages:, max_words: DEFAULT_MAX_WORDS)
58
+ def summarize_to_multiple(text, languages:, max_words: DEFAULT_MAX_WORDS, context: nil, style: nil)
80
59
  log_debug("Starting summarize_to_multiple - languages: #{languages}")
81
60
  validate_multiple_summarize_inputs!(text, languages, max_words)
82
61
 
83
62
  target_locales = Array(languages).map { |locale| LocaleHelper.validate_locale!(locale) }
84
63
  cleaned_text = clean_document_content(text)
85
- results = {}
86
-
87
- target_locales.each_with_index do |target_locale, index|
88
- log_debug("Processing language #{index + 1}/#{target_locales.length}: #{target_locale}")
89
64
 
65
+ target_locales.each_with_index.to_h do |target_locale, index|
90
66
  # Ajouter un délai seulement entre les requêtes (pas avant la première)
91
- if index.positive?
92
- log_debug("Adding delay between requests: #{DEFAULT_RETRY_DELAY}s")
93
- sleep(DEFAULT_RETRY_DELAY)
94
- end
67
+ sleep(DEFAULT_RETRY_DELAY) if index.positive?
95
68
 
96
- results[target_locale] = summarize_with_retry(cleaned_text, target_locale, max_words)
69
+ prompt = PromptBuilder.summary_prompt(cleaned_text, max_words, target_locale, context: context, style: style)
70
+ [target_locale, request_summary(prompt, target_locale)]
97
71
  end
98
-
99
- log_debug("Multiple summarization completed")
100
- results
101
72
  end
102
73
 
103
74
  # Résumé par niveaux (court, moyen, long)
104
- def summarize_tiered(text, language: "fr", short: 50, medium: 150, long: 300)
75
+ def summarize_tiered(text, language: "fr", short: 50, medium: 150, long: 300, context: nil, style: nil) # rubocop:disable Metrics/ParameterLists
105
76
  log_debug("Starting summarize_tiered - short: #{short}, medium: #{medium}, long: #{long}")
106
77
  validate_tiered_inputs!(text, language, short, medium, long)
107
78
 
108
79
  target_locale = LocaleHelper.validate_locale!(language)
109
80
  cleaned_text = clean_document_content(text)
110
81
 
111
- {
112
- short: summarize_with_retry(cleaned_text, target_locale, short),
113
- medium: summarize_with_retry(cleaned_text, target_locale, medium),
114
- long: summarize_with_retry(cleaned_text, target_locale, long)
115
- }
82
+ { short: short, medium: medium, long: long }.transform_values do |max_words|
83
+ prompt = PromptBuilder.summary_prompt(cleaned_text, max_words, target_locale, context: context, style: style)
84
+ request_summary(prompt, target_locale)
85
+ end
116
86
  end
117
87
 
118
88
  private
119
89
 
120
- # rubocop:disable Metrics/PerceivedComplexity
121
- def summarize_with_retry(text, target_locale, max_words, attempt = 0)
122
- log_debug("Summarize attempt #{attempt + 1} for #{target_locale}")
123
-
124
- prompt = PromptBuilder.summary_prompt(text, max_words, target_locale)
125
- raw_response = @client.complete(prompt)
126
- response_len = raw_response&.length || 0
127
-
128
- result = ResponseParser.parse_summary_response(raw_response)
129
- if result.nil? || result[:summary].nil? || result[:summary].empty?
130
- log_error(
131
- "Empty or invalid summary response for #{target_locale}. " \
132
- "Raw response length: #{response_len}"
133
- )
134
- raise EmptyTranslationError, "Empty summary received"
135
- end
90
+ # Commun à tous les résumés ; une réponse inexploitable finit par lever une erreur.
91
+ def request_summary(prompt, target_locale)
92
+ with_retries(operation: "summary", subject: target_locale) do |attempt|
93
+ log_debug("Summary attempt #{attempt + 1} for #{target_locale}")
136
94
 
137
- log_debug("Summary successful for #{target_locale}")
138
- result[:summary]
139
- rescue EmptyTranslationError, InvalidResponseError => e
140
- if attempt < DEFAULT_RETRY_COUNT
141
- wait_time = DEFAULT_RETRY_DELAY * (2**attempt)
142
- log_retry(e, attempt + 1, wait_time, target_locale)
143
- sleep(wait_time)
144
- summarize_with_retry(text, target_locale, max_words, attempt + 1)
145
- else
146
- log_debug("Max retries reached for #{target_locale}, giving up")
147
- raise e
148
- end
149
- rescue RateLimitError
150
- # Le client a déjà relancé avec backoff : quelques tentatives de plus suffisent.
151
- raise if attempt >= DEFAULT_RETRY_COUNT
95
+ result = ResponseParser.parse_summary_response(@client.complete(prompt, json: true))
96
+ raise EmptyTranslationError, "Empty summary received" if result.nil? || result[:summary].to_s.empty?
152
97
 
153
- log_rate_limit_hit("summary", target_locale)
154
- sleep(DEFAULT_RETRY_DELAY)
155
- summarize_with_retry(text, target_locale, max_words, attempt + 1)
156
- end
157
- # rubocop:enable Metrics/PerceivedComplexity
158
-
159
- def build_summary_translation_prompt(text, source_locale, target_locale, max_words)
160
- source_name = LocaleHelper.locale_to_language(source_locale)
161
- target_name = LocaleHelper.locale_to_language(target_locale)
162
-
163
- <<~PROMPT
164
- Tu es un assistant spécialisé dans la création de résumés et traductions simultanées.#{" "}
165
- Résume ET traduis le texte suivant en respectant ces règles strictes :
166
- 1. Langue source : #{source_name} (#{source_locale})
167
- 2. Langue cible : #{target_name} (#{target_locale})
168
- 3. Longueur maximale : #{max_words} mots
169
- 4. Créer un résumé du texte ET le traduire vers la langue cible
170
- 5. Format de réponse obligatoire en JSON :
171
- {
172
- "content": {
173
- "source": "texte original",
174
- "target": "résumé traduit en #{target_name}"
175
- },
176
- "metadata": {
177
- "source_language": "#{source_locale}",
178
- "target_language": "#{target_locale}",
179
- "max_words": #{max_words},
180
- "operation": "summarize_and_translate"
181
- }
182
- }
183
-
184
- Texte à résumer et traduire :
185
- #{text}
186
- PROMPT
98
+ result[:summary]
99
+ end
187
100
  end
188
101
 
189
102
  def clean_document_content(content)
@@ -211,6 +124,8 @@ module MistralTranslator
211
124
 
212
125
  def validate_summarize_inputs!(text, language, max_words)
213
126
  raise ArgumentError, "Text cannot be nil or empty" if text.nil? || text.empty?
127
+
128
+ Security::BasicValidator.validate_text!(text)
214
129
  raise ArgumentError, "Language cannot be nil" if language.nil?
215
130
  raise ArgumentError, "Max words must be a positive integer" unless max_words.is_a?(Integer) && max_words.positive?
216
131
  end
@@ -233,36 +148,8 @@ module MistralTranslator
233
148
  raise ArgumentError, "Long length must be greater than medium" unless long > medium
234
149
  end
235
150
 
236
- def log_retry(error, attempt, wait_time, locale)
237
- message = "Summary retry #{attempt}/#{DEFAULT_RETRY_COUNT} for #{locale} in #{wait_time}s: #{error.message}"
238
- # Log une seule fois par locale et type d'erreur
239
- Logger.warn_once(message, key: "summary_retry_#{locale}_#{error.class.name}", sensitive: false, ttl: 120)
240
- end
241
-
242
- def log_rate_limit_hit(operation, locale)
243
- message = "Rate limit hit for #{operation} in #{locale}, retrying..."
244
- # Log une seule fois par opération et locale
245
- Logger.warn_once(message, key: "summary_rate_limit_#{operation}_#{locale}", sensitive: false, ttl: 300)
246
- end
247
-
248
151
  def log_debug(message)
249
- # Log de debug seulement si mode verbose activé
250
152
  Logger.debug_if_verbose(message, sensitive: false)
251
-
252
- # Pour les tests, permettre un output dans stdout si nécessaire
253
- return unless ENV["MISTRAL_TRANSLATOR_TEST_OUTPUT"] == "true"
254
-
255
- puts "[MistralTranslator] #{message}"
256
- end
257
-
258
- def log_error(message)
259
- # Utiliser le logger centralisé
260
- Logger.warn(message, sensitive: false)
261
-
262
- # Pour les tests, optionnellement afficher en stdout
263
- return unless ENV["MISTRAL_TRANSLATOR_TEST_OUTPUT"] == "true"
264
-
265
- puts "[MistralTranslator] ERROR: #{message}"
266
153
  end
267
154
  end
268
155
  end
@@ -1,21 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "logger"
4
+ require_relative "retries"
4
5
  require_relative "translator_helpers"
5
6
  require_relative "security"
6
7
 
7
8
  module MistralTranslator
8
9
  class Translator
10
+ include Retries
9
11
  include TranslatorHelpers::InputValidator
10
- include TranslatorHelpers::RetryHandler
11
- include TranslatorHelpers::LoggingHelper
12
- include TranslatorHelpers::PromptHandler
13
12
  include TranslatorHelpers::AnalysisHelper
14
13
  include TranslatorHelpers::RequestHelper
15
14
  include TranslatorHelpers::MultiTargetHelper
16
15
 
17
- DEFAULT_RETRY_COUNT = 3
18
- DEFAULT_RETRY_DELAY = 2
16
+ DEFAULT_RETRY_COUNT = Retries::RETRY_COUNT
17
+ DEFAULT_RETRY_DELAY = Retries::RETRY_DELAY
19
18
 
20
19
  def initialize(client: nil)
21
20
  @client = client || Client.new
@@ -67,6 +66,7 @@ module MistralTranslator
67
66
  # Traduction vers plusieurs langues avec support du batch
68
67
  def translate_to_multiple(text, from:, to:, **options)
69
68
  validate_translation_inputs!(text, from, to)
69
+ Security::BasicValidator.validate_text!(text)
70
70
 
71
71
  source_locale = LocaleHelper.validate_locale!(from)
72
72
  target_locales = Array(to).map { |locale| LocaleHelper.validate_locale!(locale) }
@@ -82,33 +82,33 @@ module MistralTranslator
82
82
  end
83
83
 
84
84
  # Traduction en lot (plusieurs textes vers une langue)
85
- def translate_batch(texts, from:, to:, context: nil, glossary: nil)
85
+ def translate_batch(texts, from:, to:, context: nil, glossary: nil, preserve_html: false) # rubocop:disable Metrics/ParameterLists
86
86
  validated_texts = Security::BasicValidator.validate_batch!(texts)
87
87
  source_locale = LocaleHelper.validate_locale!(from)
88
88
  target_locale = LocaleHelper.validate_locale!(to)
89
89
 
90
90
  return handle_same_language_batch(validated_texts) if source_locale == target_locale
91
91
 
92
- process_mixed_batch(validated_texts, source_locale, target_locale, context, glossary)
92
+ batch_config = { source_locale: source_locale, target_locale: target_locale, context: context,
93
+ glossary: glossary, preserve_html: preserve_html }
94
+ process_mixed_batch(validated_texts, batch_config)
93
95
  end
94
96
 
95
97
  # Auto-détection de la langue source avec support du contexte
96
- def translate_auto(text, to:, context: nil, glossary: nil)
98
+ def translate_auto(text, to:, context: nil, glossary: nil, preserve_html: false)
99
+ validated_text = Security::BasicValidator.validate_text!(text)
100
+ return "" if validated_text.empty?
101
+
97
102
  target_locale = LocaleHelper.validate_locale!(to)
98
103
 
99
- # Premier appel pour détecter la langue
100
- detection_prompt = build_language_detection_prompt(text)
101
- detection_response = @client.complete(detection_prompt)
104
+ # Premier appel pour détecter la langue ; un échec lève une erreur
105
+ detection_response = @client.complete(build_language_detection_prompt(validated_text), json: true)
102
106
  detected_language = parse_language_detection(detection_response)
103
-
104
- # Vérifier que la langue détectée est différente de la cible
105
- if detected_language == target_locale
106
- # Si même langue, retourner le texte original
107
- return text
108
- end
107
+ return validated_text if detected_language == target_locale
109
108
 
110
109
  # Puis traduction normale avec contexte
111
- translate(text, from: detected_language, to: target_locale, context: context, glossary: glossary)
110
+ translate(validated_text, from: detected_language, to: target_locale, context: context, glossary: glossary,
111
+ preserve_html: preserve_html)
112
112
  end
113
113
 
114
114
  private
@@ -119,10 +119,8 @@ module MistralTranslator
119
119
  validated_texts.each_with_index.to_h { |text, index| [index, text] }
120
120
  end
121
121
 
122
- def process_mixed_batch(validated_texts, source_locale, target_locale, context, glossary)
122
+ def process_mixed_batch(validated_texts, batch_config)
123
123
  results = {}
124
- batch_config = { source_locale: source_locale, target_locale: target_locale, context: context,
125
- glossary: glossary }
126
124
  requests = build_batch_requests(validated_texts, batch_config, results)
127
125
 
128
126
  process_batch_requests(requests, results) if requests.any?
@@ -147,7 +145,8 @@ module MistralTranslator
147
145
  def create_batch_request(text, batch_config, index)
148
146
  {
149
147
  prompt: build_translation_prompt(text, batch_config[:source_locale], batch_config[:target_locale],
150
- context: batch_config[:context], glossary: batch_config[:glossary]),
148
+ context: batch_config[:context], glossary: batch_config[:glossary],
149
+ preserve_html: batch_config[:preserve_html]),
151
150
  from: batch_config[:source_locale],
152
151
  to: batch_config[:target_locale],
153
152
  index: index,
@@ -172,86 +171,25 @@ module MistralTranslator
172
171
  parsed_result[:translated] if parsed_result
173
172
  rescue EmptyTranslationError
174
173
  ""
174
+ rescue InvalidResponseError
175
+ # Une réponse illisible fait échouer son élément, pas tout le lot
176
+ nil
175
177
  end
176
178
 
177
- def translate_with_retry(text, source_locale, target_locale, attempt = 0, **options)
178
- prompt = build_prompt_for_retry(text, source_locale, target_locale, **options)
179
- request_context = build_request_context(source_locale, target_locale, attempt)
179
+ def translate_with_retry(text, source_locale, target_locale, **)
180
+ prompt = build_translation_prompt(text, source_locale, target_locale, **)
180
181
 
181
- raw_response = perform_client_request(prompt, request_context)
182
- extract_translated_text!(raw_response)
183
- rescue EmptyTranslationError, InvalidResponseError => e
184
- handle_retryable_error!(e, attempt) do
185
- translate_with_retry(text, source_locale, target_locale, attempt + 1, **options)
182
+ with_retries(operation: "translation", subject: "#{source_locale}->#{target_locale}") do |attempt|
183
+ raw_response = perform_client_request(prompt, build_request_context(source_locale, target_locale, attempt))
184
+ extract_translated_text!(raw_response)
186
185
  end
187
- rescue RateLimitError
188
- # Le client a déjà relancé avec backoff : quelques tentatives de plus suffisent.
189
- raise if attempt >= DEFAULT_RETRY_COUNT
190
-
191
- log_rate_limit_hit(source_locale, target_locale)
192
- sleep(DEFAULT_RETRY_DELAY)
193
- translate_with_retry(text, source_locale, target_locale, attempt + 1, **options)
194
186
  end
195
187
 
196
- # Ces méthodes sont héritées via RequestHelper
197
-
198
- # Log via LoggingHelper
199
-
200
188
  def build_translation_prompt(text, source_locale, target_locale, **options)
201
- base_prompt = PromptBuilder.translation_prompt(text, source_locale, target_locale,
202
- preserve_html: options.fetch(:preserve_html, false))
203
-
204
- # Enrichir le prompt avec le contexte et le glossaire
205
- context_present = options[:context] && !options[:context].to_s.strip.empty?
206
- glossary_present =
207
- (options[:glossary].is_a?(Hash) && options[:glossary].any?) ||
208
- (options[:glossary].is_a?(String) && !options[:glossary].to_s.strip.empty?)
209
- if context_present || glossary_present
210
- enrich_prompt_with_context(base_prompt, options[:context], options[:glossary])
211
- else
212
- base_prompt
213
- end
214
- end
215
-
216
- def enrich_prompt_with_context(base_prompt, context, glossary)
217
- enriched_parts = []
218
- context_part = build_context_enrichment(context)
219
- glossary_part = build_glossary_enrichment(glossary)
220
- enriched_parts << context_part if context_part
221
- enriched_parts << glossary_part if glossary_part
222
-
223
- return base_prompt if enriched_parts.empty?
224
-
225
- enriched_context = enriched_parts.join("\n")
226
- base_prompt.sub("RÈGLES :", "#{enriched_context}\n\nRÈGLES :")
227
- end
228
-
229
- def build_context_enrichment(context)
230
- return nil if context.nil? || context.to_s.strip.empty?
231
-
232
- "CONTEXTE : #{context}"
233
- end
234
-
235
- def build_glossary_enrichment(glossary)
236
- return nil if glossary.nil?
237
- return build_glossary_hash(glossary) if glossary.is_a?(Hash)
238
- return build_glossary_string(glossary) if glossary.is_a?(String)
239
-
240
- nil
241
- end
242
-
243
- def build_glossary_hash(glossary_hash)
244
- return nil if glossary_hash.empty?
245
-
246
- glossary_text = glossary_hash.map { |key, value| "#{key} → #{value}" }.join(", ")
247
- "GLOSSAIRE (à respecter strictement) : #{glossary_text}"
248
- end
249
-
250
- def build_glossary_string(glossary_string)
251
- value = glossary_string.to_s.strip
252
- return nil if value.empty?
253
-
254
- "GLOSSAIRE : #{value}"
189
+ PromptBuilder.translation_prompt(text, source_locale, target_locale,
190
+ context: options[:context],
191
+ glossary: options[:glossary],
192
+ preserve_html: options.fetch(:preserve_html, false))
255
193
  end
256
194
  end
257
195
  end
@@ -20,103 +20,6 @@ module MistralTranslator
20
20
  validate_inputs!(text, from, to)
21
21
  raise ArgumentError, "Target languages cannot be empty" if Array(to).empty?
22
22
  end
23
-
24
- def validate_batch_inputs!(texts, from, to)
25
- raise ArgumentError, "Texts array cannot be nil or empty" if texts.nil? || texts.empty?
26
-
27
- ensure_present!(from, "Source language")
28
- ensure_present!(to, "Target language")
29
-
30
- texts.each_with_index do |t, i|
31
- ensure_present!(t, "Text at index #{i}")
32
- end
33
- end
34
-
35
- private
36
-
37
- def ensure_present!(value, field_name)
38
- raise ArgumentError, "#{field_name} cannot be nil or empty" if value.nil? || value.to_s.strip.empty?
39
- end
40
- end
41
-
42
- # Helper pour la gestion des retry
43
- module RetryHandler
44
- DEFAULT_RETRY_DELAY = 2
45
-
46
- def handle_retry_error(error, source_locale, target_locale, attempt)
47
- case error
48
- when RateLimitError
49
- handle_rate_limit_retry?(source_locale, target_locale, attempt)
50
- when ApiError
51
- handle_api_error_retry?(error, source_locale, target_locale, attempt)
52
- else
53
- raise error
54
- end
55
- end
56
-
57
- # Renvoie true si un retry a été effectué
58
- def handle_rate_limit_retry?(source_locale, target_locale, _attempt)
59
- log_rate_limit_hit(source_locale, target_locale)
60
- sleep(DEFAULT_RETRY_DELAY)
61
- # NOTE: retry logic should be handled by the calling method
62
- true
63
- end
64
-
65
- # Renvoie true si un retry a été effectué
66
- def handle_api_error_retry?(error, _source_locale, _target_locale, attempt)
67
- raise error unless attempt < 3 && error.message.include?("timeout")
68
-
69
- Logger.warn("API timeout, retrying... (attempt #{attempt + 1})")
70
- sleep(DEFAULT_RETRY_DELAY)
71
- # NOTE: retry logic should be handled by the calling method
72
- true
73
- end
74
- end
75
-
76
- # Helper pour le logging
77
- module LoggingHelper
78
- def log_rate_limit_hit(source_locale, target_locale)
79
- message = "Rate limit hit for #{source_locale}->#{target_locale}"
80
- Logger.warn_once(message, key: "rate_limit_#{source_locale}_#{target_locale}", sensitive: false, ttl: 300)
81
- end
82
-
83
- def log_translation_attempt(source_locale, target_locale, attempt)
84
- Logger.debug("Translation attempt #{attempt} for #{source_locale}->#{target_locale}")
85
- end
86
-
87
- def log_retry(error, attempt, wait_time)
88
- default_retry_count = MistralTranslator::Translator::DEFAULT_RETRY_COUNT
89
- message = "Translation retry #{attempt}/#{default_retry_count} in #{wait_time}s: #{error.message}"
90
- Logger.warn_once(message, key: "translate_retry_#{error.class.name}_#{attempt}", sensitive: false, ttl: 120)
91
- end
92
- end
93
-
94
- # Helper pour la gestion des prompts
95
- module PromptHandler
96
- def enrich_prompt_with_context(base_prompt, context, glossary)
97
- context_section = build_context_section(context, glossary)
98
- base_prompt + context_section
99
- end
100
-
101
- def build_context_section(context, glossary)
102
- context_section = ""
103
- context_section += add_context_text(context) if context && !context.to_s.strip.empty?
104
- context_section += add_glossary_text(glossary) if glossary && !glossary.to_s.strip.empty?
105
- context_section
106
- end
107
-
108
- def add_context_text(context)
109
- "\nCONTEXTE : #{context}\n"
110
- end
111
-
112
- def add_glossary_text(glossary)
113
- if glossary.is_a?(Hash) && glossary.any?
114
- glossary_text = glossary.map { |key, value| "#{key} → #{value}" }.join(", ")
115
- "\nGLOSSAIRE (à respecter strictement) : #{glossary_text}\n"
116
- else
117
- "\nGLOSSAIRE : #{glossary}\n"
118
- end
119
- end
120
23
  end
121
24
 
122
25
  # Helper pour l'analyse et la détection de langue
@@ -152,32 +55,22 @@ module MistralTranslator
152
55
  PromptBuilder.language_detection_prompt(text)
153
56
  end
154
57
 
58
+ # Fail-closed : une détection illisible ou une langue non supportée lève une erreur,
59
+ # au lieu de supposer une langue et de renvoyer le texte non traduit.
155
60
  def parse_language_detection(response)
156
- json_content = response.match(/\{.*\}/m)&.[](0)
157
- return "en" unless json_content
158
-
159
- data = JSON.parse(json_content)
160
- detected = data.dig("metadata", "detected_language")
61
+ data = JSON.parse(response.to_s)
62
+ metadata = data["metadata"] if data.is_a?(Hash)
63
+ detected = LocaleHelper.normalize_locale(metadata["detected_language"]) if metadata.is_a?(Hash)
64
+ return detected if detected && LocaleHelper.locale_supported?(detected)
161
65
 
162
- LocaleHelper.locale_supported?(detected) ? detected : "en"
66
+ raise InvalidResponseError.new("Language detection failed", response)
163
67
  rescue JSON::ParserError
164
- "en"
68
+ raise InvalidResponseError.new("Language detection failed", response), cause: nil
165
69
  end
166
70
  end
167
71
 
168
72
  # Helper pour requêtes client et retries
169
73
  module RequestHelper
170
- def build_prompt_for_retry(text, source_locale, target_locale, **options)
171
- build_translation_prompt(
172
- text,
173
- source_locale,
174
- target_locale,
175
- context: options[:context],
176
- glossary: options[:glossary],
177
- preserve_html: options.fetch(:preserve_html, false)
178
- )
179
- end
180
-
181
74
  def build_request_context(source_locale, target_locale, attempt)
182
75
  {
183
76
  from_locale: source_locale,
@@ -187,43 +80,16 @@ module MistralTranslator
187
80
  end
188
81
 
189
82
  def perform_client_request(prompt, request_context)
190
- if @client.respond_to?(:complete)
191
- begin
192
- return @client.complete(prompt, context: request_context)
193
- rescue StandardError => e
194
- raise e unless e.class.name.include?("MockExpectationError") && @client.respond_to?(:make_request)
195
-
196
- return @client.make_request(prompt, nil, nil)
197
- end
198
- end
199
-
200
- return @client.make_request(prompt, nil, nil) if @client.respond_to?(:make_request)
201
-
202
- @client.complete(prompt, context: request_context)
83
+ @client.complete(prompt, context: request_context, json: true)
203
84
  end
204
85
 
86
+ # Une réponse inexploitable lève une erreur : jamais de réponse brute enregistrée comme traduction.
205
87
  def extract_translated_text!(raw_response)
206
- begin
207
- result = ResponseParser.parse_translation_response(raw_response)
208
- return result[:translated] if result && !result[:translated].to_s.empty?
209
- rescue EmptyTranslationError, InvalidResponseError
210
- # Fallback: accepter une réponse texte brute non vide
211
- fallback_text = raw_response.to_s.strip
212
- return fallback_text unless fallback_text.empty?
213
- # sinon on relance l'erreur plus bas
214
- end
88
+ result = ResponseParser.parse_translation_response(raw_response)
89
+ return result[:translated] if result && !result[:translated].to_s.empty?
215
90
 
216
91
  raise EmptyTranslationError
217
92
  end
218
-
219
- def handle_retryable_error!(error, attempt)
220
- raise error unless attempt < MistralTranslator::Translator::DEFAULT_RETRY_COUNT
221
-
222
- wait_time = MistralTranslator::Translator::DEFAULT_RETRY_DELAY * (2**attempt)
223
- log_retry(error, attempt + 1, wait_time)
224
- sleep(wait_time)
225
- yield
226
- end
227
93
  end
228
94
 
229
95
  # Helper pour traductions multi-cibles
@@ -248,14 +114,20 @@ module MistralTranslator
248
114
  batch_results.each do |result|
249
115
  next unless result[:success]
250
116
 
251
- target_locale = result[:original_request][:to]
252
- parsed_result = ResponseParser.parse_translation_response(result[:result])
253
- results[target_locale] = parsed_result[:translated] if parsed_result
117
+ translated = parse_batch_translation(result[:result])
118
+ results[result[:original_request][:to]] = translated if translated
254
119
  end
255
120
 
256
121
  results
257
122
  end
258
123
 
124
+ # Une réponse illisible fait échouer sa langue, pas tout le lot
125
+ def parse_batch_translation(response)
126
+ ResponseParser.parse_translation_response(response)&.fetch(:translated)
127
+ rescue EmptyTranslationError, InvalidResponseError
128
+ nil
129
+ end
130
+
259
131
  def translate_to_multiple_sequential(text, source_locale, target_locales, **options)
260
132
  options = DEFAULT_MULTI_TARGET_OPTIONS.merge(options)
261
133
  results = {}