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 +4 -4
- data/CHANGELOG.md +22 -0
- data/lib/mistral_translator/adapters.rb +73 -33
- data/lib/mistral_translator/client_helpers.rb +2 -2
- data/lib/mistral_translator/helpers.rb +4 -3
- data/lib/mistral_translator/prompt_helpers.rb +0 -8
- data/lib/mistral_translator/prompt_metadata_helpers.rb +8 -12
- data/lib/mistral_translator/security.rb +16 -14
- data/lib/mistral_translator/summarizer.rb +5 -2
- data/lib/mistral_translator/translator.rb +9 -5
- data/lib/mistral_translator/translator_helpers.rb +9 -4
- data/lib/mistral_translator/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 242809c8a55ea79c9c436a2fc7b17d7a6deda5372090eaaa43b9a0863e1ba552
|
|
4
|
+
data.tar.gz: b6f86bc08b253f5ea4097e9fa1f3e901e41886067155c2fc595202694ce18acb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
264
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 =
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
266
|
+
translate_with_retry(text, source_locale, target_locale, **options)
|
|
262
267
|
end
|
|
263
268
|
|
|
264
269
|
results
|