mistral_translator 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 278978acb1df0d447293200f13ff58745640a62f5bf43a41ebc7b31db2d50b98
4
- data.tar.gz: c86fc021d49a554fd4d44883e1383a5ea4ba82251074b4b358b3b5cf97f27fd2
3
+ metadata.gz: 242809c8a55ea79c9c436a2fc7b17d7a6deda5372090eaaa43b9a0863e1ba552
4
+ data.tar.gz: b6f86bc08b253f5ea4097e9fa1f3e901e41886067155c2fc595202694ce18acb
5
5
  SHA512:
6
- metadata.gz: ca6e0c3b2e2346ab8432dfc24080adcb994f75df453111851d6f8441465d09107d59216fc84efc1f84acf823a4a4ec284c5d1b1212c8ceda9a3d8861e95a2208
7
- data.tar.gz: 39b55072eb24870acd861b8c7ec7c8aa279a9f7b03b4a84f0cdabd4982c2d9f04da1b39b2eea05024d6e3a2c1bc7737ee95557f0eaa588c61ec3fbc5d04af540
6
+ metadata.gz: 6183a2226ee0985e5229c2cb203fed6cfb71ced4c2a0cdb28fba16115f5b6fdf28b860baba50db8f3461e64792267558f3413481e715c54f2f1b5bf0747d3647
7
+ data.tar.gz: a71be86ea71b15e50af048cbba672e5fdecd41e6d3cd1e1313d534d11d2af7747ac593cf36791ad805f9b6db4ff6d6d88853a8b166a046cc45747a66e879bf0c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.2] - 2026-10-28
4
+
5
+ ### Fixed
6
+
7
+ - Rich text adapters send the stored ActionText HTML (`to_html`) instead of the rendered one (`to_s`), which wrapped every translation in an extra `<div class="trix-content">` and inlined the rendered attachments. `ActionText::Content` values are now recognised too, not only `ActionText::RichText`.
8
+ - `RecordTranslationService` passes `preserve_html: true` for rich text fields.
9
+ - `RecordTranslationService#translate_to_all_locales` returns `false` when a translation fails. It returned `true`, so failures went unnoticed; successful translations are still saved.
10
+ - `RecordTranslationService`, `Translator` and `Summarizer` stop retrying after a few `RateLimitError`s and raise, instead of retrying forever on top of the client's own backoff.
11
+ - `translate_to_multiple` passes `preserve_html` to each translation; the option was ignored.
12
+ - `translate_with_mistral` raised `ArgumentError` on every call. `from:` is now the source locale and `to:` the target locales, through a new `target_locales:` option of `RecordTranslationService`.
13
+ - A glossary given as a string no longer raises `NoMethodError` in `PromptBuilder`.
14
+ - `BasicRateLimiter` no longer sleeps while holding its lock, which blocked every other thread.
15
+ - HTTP error messages no longer end with a stray brace.
16
+
17
+ ### Changed
18
+
19
+ - CI token restricted to read access, and development dependencies updated (addressable, json, uri).
20
+
21
+ ### Removed
22
+
23
+ - Unused helpers `log_prompt_debug` and `build_html_validation_instruction`.
24
+
3
25
  ## [0.3.1] - 2026-10-27
4
26
 
5
27
  ### Fixed
@@ -43,24 +43,35 @@ module MistralTranslator
43
43
  value = get_field_value(field, locale)
44
44
  return false if value.nil?
45
45
 
46
- # Gestion des rich text
47
- if defined?(ActionText::RichText) && value.is_a?(ActionText::RichText)
48
- !value.to_plain_text.to_s.strip.empty?
49
- else
50
- !value.to_s.strip.empty?
51
- end
46
+ text = rich_text?(value) ? value.to_plain_text : value
47
+ !text.to_s.strip.empty?
52
48
  end
53
49
 
54
50
  def get_translatable_content(field, locale)
55
51
  value = get_field_value(field, locale)
56
52
  return nil if value.nil?
57
53
 
58
- # Gestion des rich text - préserver le HTML
59
- if defined?(ActionText::RichText) && value.is_a?(ActionText::RichText)
60
- value.body.to_s
61
- else
62
- value.to_s
63
- end
54
+ rich_text?(value) ? rich_text_html(value) : value.to_s
55
+ end
56
+
57
+ # ActionText::RichText (has_rich_text) ou ActionText::Content (son body)
58
+ def rich_text?(value)
59
+ (defined?(ActionText::RichText) && value.is_a?(ActionText::RichText)) ||
60
+ (defined?(ActionText::Content) && value.is_a?(ActionText::Content))
61
+ end
62
+
63
+ def rich_text_field?(field, locale)
64
+ rich_text?(get_field_value(field, locale))
65
+ end
66
+
67
+ private
68
+
69
+ # HTML tel que stocké, pièces jointes réduites à <action-text-attachment>.
70
+ # to_s rendrait le layout (<div class="trix-content">) et le contenu des
71
+ # pièces jointes, qui seraient alors enregistrés dans la traduction.
72
+ def rich_text_html(value)
73
+ content = value.respond_to?(:body) ? value.body : value
74
+ content&.to_html.to_s
64
75
  end
65
76
  end
66
77
 
@@ -167,16 +178,22 @@ module MistralTranslator
167
178
  # Service de traduction utilisant les adaptateurs
168
179
  module Adapters
169
180
  class RecordTranslationService
170
- def initialize(record, translatable_fields, adapter: nil, source_locale: nil)
181
+ MAX_RATE_LIMIT_RETRIES = 3
182
+
183
+ # target_locales : toutes les locales de l'adaptateur par défaut
184
+ def initialize(record, translatable_fields, adapter: nil, source_locale: nil, target_locales: nil)
171
185
  @record = record
172
186
  @translatable_fields = Array(translatable_fields)
173
187
  @adapter = adapter || Adapters::AdapterFactory.build_for(record)
174
188
  @source_locale = source_locale
189
+ @target_locales = target_locales&.map(&:to_sym)
175
190
  end
176
191
 
192
+ # Enregistre les traductions obtenues ; renvoie false si l'une a échoué.
177
193
  def translate_to_all_locales
178
194
  return false if @translatable_fields.empty?
179
195
 
196
+ @failed = false
180
197
  ActiveRecord::Base.transaction do
181
198
  @translatable_fields.each do |field|
182
199
  translate_field(field)
@@ -184,7 +201,7 @@ module MistralTranslator
184
201
  @adapter.save_record!
185
202
  end
186
203
 
187
- true
204
+ !@failed
188
205
  rescue StandardError => e
189
206
  Rails.logger.error "Translation failed: #{e.message}" if defined?(Rails)
190
207
  false
@@ -198,34 +215,57 @@ module MistralTranslator
198
215
 
199
216
  return if source_content.nil? || source_content.strip.empty?
200
217
 
218
+ options = rich_text_field?(field, source_locale) ? { preserve_html: true } : {}
201
219
  target_locales = @adapter.available_locales - [source_locale]
220
+ target_locales &= @target_locales if @target_locales
202
221
 
203
222
  target_locales.each do |target_locale|
204
- translate_to_locale(field, source_content, source_locale, target_locale)
205
- sleep(2) # Rate limiting basique
223
+ translated = translate_to_locale(field, source_content, source_locale, target_locale, options)
224
+ sleep(2) if translated # Rate limiting basique
206
225
  end
207
226
  end
208
227
 
209
- def translate_to_locale(field, content, source_locale, target_locale)
210
- translated_content = MistralTranslator.translate(
211
- content,
212
- from: source_locale.to_s,
213
- to: target_locale.to_s
214
- )
228
+ # Les adaptateurs fournis par l'application n'héritent pas forcément de BaseAdapter.
229
+ def rich_text_field?(field, locale)
230
+ @adapter.respond_to?(:rich_text_field?) && @adapter.rich_text_field?(field, locale)
231
+ end
215
232
 
216
- if translated_content && !translated_content.strip.empty?
217
- @adapter.set_field_value(field, target_locale,
218
- translated_content)
219
- end
220
- rescue MistralTranslator::RateLimitError => e
221
- Rails.logger.warn "Rate limit: #{e.message}" if defined?(Rails)
222
- sleep(2)
223
- retry
224
- rescue StandardError => e
225
- if defined?(Rails)
226
- Rails.logger.error "Translation error for #{field} #{source_locale}->#{target_locale}: #{e.message}"
233
+ def translate_to_locale(field, content, source_locale, target_locale, options)
234
+ rate_limit_retries = 0
235
+ begin
236
+ translated_content = MistralTranslator.translate(
237
+ content,
238
+ from: source_locale.to_s,
239
+ to: target_locale.to_s,
240
+ **options
241
+ )
242
+
243
+ if translated_content && !translated_content.strip.empty?
244
+ @adapter.set_field_value(field, target_locale,
245
+ translated_content)
246
+ end
247
+ true
248
+ rescue MistralTranslator::RateLimitError => e
249
+ Rails.logger.warn "Rate limit: #{e.message}" if defined?(Rails)
250
+ rate_limit_retries += 1
251
+ if rate_limit_retries <= MAX_RATE_LIMIT_RETRIES
252
+ sleep(2)
253
+ retry
254
+ end
255
+ record_failure(field, source_locale, target_locale, e)
256
+ false
257
+ rescue StandardError => e
258
+ record_failure(field, source_locale, target_locale, e)
259
+ false
227
260
  end
228
261
  end
262
+
263
+ def record_failure(field, source_locale, target_locale, error)
264
+ @failed = true
265
+ return unless defined?(Rails)
266
+
267
+ Rails.logger.error "Translation error for #{field} #{source_locale}->#{target_locale}: #{error.message}"
268
+ end
229
269
  end
230
270
  end
231
271
 
@@ -101,9 +101,9 @@ module MistralTranslator
101
101
  # Rate limit sera géré séparément
102
102
  nil
103
103
  when 400..499
104
- raise ApiError, "Client error (#{response.code})}"
104
+ raise ApiError, "Client error (#{response.code})"
105
105
  when 500..599
106
- raise ApiError, "Server error (#{response.code})}"
106
+ raise ApiError, "Server error (#{response.code})"
107
107
  end
108
108
  end
109
109
 
@@ -258,10 +258,11 @@ module MistralTranslator
258
258
 
259
259
  # Module pour inclure dans les classes Rails si souhaité
260
260
  module RecordHelpers
261
- def translate_with_mistral(fields, from:, to:, **)
261
+ def translate_with_mistral(fields, from:, to:)
262
262
  adapter = MistralTranslator::Adapters::AdapterFactory.build_for(self)
263
- service = MistralTranslator::Adapters::RecordTranslationService.new(self, fields, adapter: adapter, from: from,
264
- to: to, **)
263
+ service = MistralTranslator::Adapters::RecordTranslationService.new(
264
+ self, fields, adapter: adapter, source_locale: from, target_locales: Array(to)
265
+ )
265
266
  service.translate_to_all_locales
266
267
  end
267
268
 
@@ -51,14 +51,6 @@ module MistralTranslator
51
51
  Ne traduis que le contenu textuel à l'intérieur des balises.
52
52
  HTML_INSTRUCTION
53
53
  end
54
-
55
- def build_html_validation_instruction
56
- <<~HTML_VALIDATION
57
-
58
- IMPORTANT : Vérifie que le HTML est valide et bien formé.
59
- Corrige toute erreur de structure HTML si nécessaire.
60
- HTML_VALIDATION
61
- end
62
54
  end
63
55
 
64
56
  # Helper pour les instructions de formatage
@@ -8,7 +8,7 @@ module MistralTranslator
8
8
  additions = []
9
9
 
10
10
  additions << '"has_context": true' if context && !context.to_s.strip.empty?
11
- additions << '"has_glossary": true' if glossary && !glossary.to_s.strip.empty? && glossary.any?
11
+ additions << '"has_glossary": true' if glossary_present?(glossary)
12
12
  additions << '"preserve_html": true' if preserve_html
13
13
 
14
14
  additions.any? ? ",\n #{additions.join(",\n ")}" : ""
@@ -23,20 +23,16 @@ module MistralTranslator
23
23
  additions.any? ? ",\n #{additions.join(",\n ")}" : ""
24
24
  end
25
25
 
26
+ # Le glossaire est un Hash ou une chaîne : String ne répond pas à #any?
27
+ def glossary_present?(glossary)
28
+ return glossary.any? if glossary.is_a?(Hash)
29
+
30
+ !glossary.to_s.strip.empty?
31
+ end
32
+
26
33
  def log_prompt_generation(prompt_type, source_locale, target_locale)
27
34
  message = "Generated #{prompt_type} prompt for #{source_locale} -> #{target_locale}"
28
35
  Logger.debug_if_verbose(message, sensitive: false)
29
36
  end
30
-
31
- def log_prompt_debug(_prompt)
32
- return unless ENV["MISTRAL_TRANSLATOR_DEBUG"]
33
-
34
- if defined?(Rails) && Rails.respond_to?(:logger)
35
- Rails.logger.info message
36
- elsif ENV["MISTRAL_TRANSLATOR_DEBUG"]
37
- # Log de debug seulement si mode verbose activé
38
- Logger.debug_if_verbose(message, sensitive: false)
39
- end
40
- end
41
37
  end
42
38
  end
@@ -42,30 +42,32 @@ module MistralTranslator
42
42
  @mutex = Mutex.new
43
43
  end
44
44
 
45
+ # Réserve un créneau sous verrou, puis attend hors du verrou : les autres
46
+ # threads réservent les créneaux suivants au lieu d'être bloqués.
45
47
  def wait_and_record!
46
- @mutex.synchronize do
47
- cleanup_old_requests
48
- if @requests.size >= @max_requests
49
- wait_time = calculate_wait_time
50
- sleep(wait_time) if wait_time.positive?
51
- end
52
- @requests << Time.now
48
+ wait_time = @mutex.synchronize do
49
+ now = Time.now
50
+ cleanup_old_requests(now)
51
+ slot = next_slot(now)
52
+ @requests << slot
53
+ slot - now
53
54
  end
55
+ sleep(wait_time) if wait_time.positive?
54
56
  end
55
57
 
56
58
  private
57
59
 
58
- def cleanup_old_requests
59
- cutoff_time = Time.now - @window_seconds
60
+ def cleanup_old_requests(now)
61
+ cutoff_time = now - @window_seconds
60
62
  @requests.reject! { |request_time| request_time < cutoff_time }
61
63
  end
62
64
 
63
- def calculate_wait_time
64
- return 0 if @requests.empty?
65
+ # Premier instant où la fenêtre compte moins de max_requests requêtes,
66
+ # créneaux déjà réservés compris
67
+ def next_slot(now)
68
+ return now if @requests.size < @max_requests
65
69
 
66
- oldest_request = @requests.min
67
- time_until_oldest_expires = (oldest_request + @window_seconds) - Time.now
68
- [time_until_oldest_expires, 0].max
70
+ @requests.sort[@requests.size - @max_requests] + @window_seconds
69
71
  end
70
72
  end
71
73
  end
@@ -146,10 +146,13 @@ module MistralTranslator
146
146
  log_debug("Max retries reached for #{target_locale}, giving up")
147
147
  raise e
148
148
  end
149
- rescue RateLimitError => e
149
+ rescue RateLimitError
150
+ # Le client a déjà relancé avec backoff : quelques tentatives de plus suffisent.
151
+ raise if attempt >= DEFAULT_RETRY_COUNT
152
+
150
153
  log_rate_limit_hit("summary", target_locale)
151
154
  sleep(DEFAULT_RETRY_DELAY)
152
- retry
155
+ summarize_with_retry(text, target_locale, max_words, attempt + 1)
153
156
  end
154
157
  # rubocop:enable Metrics/PerceivedComplexity
155
158
 
@@ -71,12 +71,13 @@ module MistralTranslator
71
71
  source_locale = LocaleHelper.validate_locale!(from)
72
72
  target_locales = Array(to).map { |locale| LocaleHelper.validate_locale!(locale) }
73
73
 
74
+ translation_options = { context: options[:context], glossary: options[:glossary] }
75
+ translation_options[:preserve_html] = true if options[:preserve_html]
76
+
74
77
  if options[:use_batch] && target_locales.size > 3
75
- translate_to_multiple_batch(text, source_locale, target_locales, context: options[:context],
76
- glossary: options[:glossary])
78
+ translate_to_multiple_batch(text, source_locale, target_locales, **translation_options)
77
79
  else
78
- translate_to_multiple_sequential(text, source_locale, target_locales, context: options[:context],
79
- glossary: options[:glossary])
80
+ translate_to_multiple_sequential(text, source_locale, target_locales, **translation_options)
80
81
  end
81
82
  end
82
83
 
@@ -184,9 +185,12 @@ module MistralTranslator
184
185
  translate_with_retry(text, source_locale, target_locale, attempt + 1, **options)
185
186
  end
186
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
+
187
191
  log_rate_limit_hit(source_locale, target_locale)
188
192
  sleep(DEFAULT_RETRY_DELAY)
189
- retry
193
+ translate_with_retry(text, source_locale, target_locale, attempt + 1, **options)
190
194
  end
191
195
 
192
196
  # Ces méthodes sont héritées via RequestHelper
@@ -228,10 +228,14 @@ module MistralTranslator
228
228
 
229
229
  # Helper pour traductions multi-cibles
230
230
  module MultiTargetHelper
231
- def translate_to_multiple_batch(text, source_locale, target_locales, context: nil, glossary: nil)
231
+ DEFAULT_MULTI_TARGET_OPTIONS = { context: nil, glossary: nil }.freeze
232
+
233
+ # options : context, glossary, preserve_html
234
+ def translate_to_multiple_batch(text, source_locale, target_locales, **options)
235
+ options = DEFAULT_MULTI_TARGET_OPTIONS.merge(options)
232
236
  requests = target_locales.map do |target_locale|
233
237
  {
234
- prompt: build_translation_prompt(text, source_locale, target_locale, context: context, glossary: glossary),
238
+ prompt: build_translation_prompt(text, source_locale, target_locale, **options),
235
239
  from: source_locale,
236
240
  to: target_locale,
237
241
  original_text: text
@@ -252,13 +256,14 @@ module MistralTranslator
252
256
  results
253
257
  end
254
258
 
255
- def translate_to_multiple_sequential(text, source_locale, target_locales, context: nil, glossary: nil)
259
+ def translate_to_multiple_sequential(text, source_locale, target_locales, **options)
260
+ options = DEFAULT_MULTI_TARGET_OPTIONS.merge(options)
256
261
  results = {}
257
262
 
258
263
  target_locales.each_with_index do |target_locale, index|
259
264
  sleep(MistralTranslator::Translator::DEFAULT_RETRY_DELAY) if index.positive?
260
265
  results[target_locale] =
261
- translate_with_retry(text, source_locale, target_locale, context: context, glossary: glossary)
266
+ translate_with_retry(text, source_locale, target_locale, **options)
262
267
  end
263
268
 
264
269
  results
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MistralTranslator
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
 
6
6
  # Informations additionnelles sur la gem
7
7
  API_VERSION = "v1"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mistral_translator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pierre Cherbero