mistral_translator 0.1.0 → 0.2.1
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 +21 -0
- data/README.md +189 -121
- data/README_TESTING.md +33 -0
- data/SECURITY.md +157 -0
- data/docs/.nojekyll +2 -0
- data/docs/404.html +30 -0
- data/docs/README.md +153 -0
- data/docs/advanced-usage/batch-processing.md +158 -0
- data/docs/advanced-usage/error-handling.md +106 -0
- data/docs/advanced-usage/monitoring.md +133 -0
- data/docs/advanced-usage/summarization.md +86 -0
- data/docs/advanced-usage/translations.md +141 -0
- data/docs/api-reference/callbacks.md +231 -0
- data/docs/api-reference/configuration.md +74 -0
- data/docs/api-reference/errors.md +673 -0
- data/docs/api-reference/methods.md +539 -0
- data/docs/getting-started.md +179 -0
- data/docs/index.html +27 -0
- data/docs/installation.md +142 -0
- data/docs/migration-0.1.0-to-0.2.0.md +61 -0
- data/docs/rails-integration/adapters.md +84 -0
- data/docs/rails-integration/controllers.md +107 -0
- data/docs/rails-integration/jobs.md +97 -0
- data/docs/rails-integration/setup.md +339 -0
- data/examples/basic_usage.rb +129 -102
- data/examples/batch-job.rb +511 -0
- data/examples/monitoring-setup.rb +499 -0
- data/examples/rails-model.rb +399 -0
- data/lib/mistral_translator/adapters.rb +261 -0
- data/lib/mistral_translator/client.rb +103 -100
- data/lib/mistral_translator/client_helpers.rb +161 -0
- data/lib/mistral_translator/configuration.rb +171 -1
- data/lib/mistral_translator/errors.rb +16 -0
- data/lib/mistral_translator/helpers.rb +292 -0
- data/lib/mistral_translator/helpers_extensions.rb +150 -0
- data/lib/mistral_translator/levenshtein_helpers.rb +40 -0
- data/lib/mistral_translator/logger.rb +28 -4
- data/lib/mistral_translator/prompt_builder.rb +93 -41
- data/lib/mistral_translator/prompt_helpers.rb +83 -0
- data/lib/mistral_translator/prompt_metadata_helpers.rb +42 -0
- data/lib/mistral_translator/response_parser.rb +194 -23
- data/lib/mistral_translator/security.rb +72 -0
- data/lib/mistral_translator/summarizer.rb +41 -2
- data/lib/mistral_translator/translator.rb +174 -98
- data/lib/mistral_translator/translator_helpers.rb +268 -0
- data/lib/mistral_translator/version.rb +1 -1
- data/lib/mistral_translator.rb +51 -25
- metadata +39 -3
@@ -0,0 +1,399 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# Exemple d'intégration MistralTranslator avec des modèles Rails
|
5
|
+
# Usage: rails runner examples/rails-model.rb
|
6
|
+
|
7
|
+
# Arrêter si le script n'est pas exécuté dans un contexte Rails
|
8
|
+
unless defined?(Rails)
|
9
|
+
warn "Ce script est destiné à être exécuté dans un projet Rails. Utilisez: rails runner examples/rails-model.rb"
|
10
|
+
exit 1
|
11
|
+
end
|
12
|
+
|
13
|
+
# Configuration initiale
|
14
|
+
MistralTranslator.configure do |config|
|
15
|
+
config.api_key = ENV.fetch("MISTRAL_API_KEY", nil)
|
16
|
+
config.enable_metrics = true
|
17
|
+
config.setup_rails_logging
|
18
|
+
end
|
19
|
+
|
20
|
+
# === EXEMPLE 1: Modèle avec Mobility ===
|
21
|
+
|
22
|
+
class Article < ApplicationRecord
|
23
|
+
extend Mobility
|
24
|
+
|
25
|
+
translates :title, :content, :description, backend: :table
|
26
|
+
|
27
|
+
# Callbacks pour traduction automatique
|
28
|
+
after_create :translate_to_all_locales, if: :should_auto_translate?
|
29
|
+
after_update :retranslate_if_changed, if: :should_auto_translate?
|
30
|
+
|
31
|
+
def translate_to_all_locales(source_locale: I18n.locale)
|
32
|
+
MistralTranslator::RecordTranslation.translate_mobility_record(
|
33
|
+
self,
|
34
|
+
%i[title content description],
|
35
|
+
source_locale: source_locale
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
def translate_to(target_locales, source_locale: I18n.locale)
|
40
|
+
Array(target_locales).each do |target_locale|
|
41
|
+
next if source_locale.to_s == target_locale.to_s
|
42
|
+
|
43
|
+
translatable_fields.each do |field|
|
44
|
+
source_text = public_send("#{field}_#{source_locale}")
|
45
|
+
next if source_text.blank?
|
46
|
+
|
47
|
+
translated = MistralTranslator.translate(
|
48
|
+
source_text,
|
49
|
+
from: source_locale,
|
50
|
+
to: target_locale,
|
51
|
+
context: "article content"
|
52
|
+
)
|
53
|
+
|
54
|
+
public_send("#{field}_#{target_locale}=", translated)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
save! if changed?
|
59
|
+
end
|
60
|
+
|
61
|
+
def estimate_translation_cost
|
62
|
+
total_chars = translatable_fields.sum do |field|
|
63
|
+
content = public_send("#{field}_#{I18n.default_locale}")
|
64
|
+
content&.length || 0
|
65
|
+
end
|
66
|
+
|
67
|
+
target_locales = I18n.available_locales - [I18n.default_locale]
|
68
|
+
|
69
|
+
{
|
70
|
+
character_count: total_chars,
|
71
|
+
target_languages: target_locales.size,
|
72
|
+
estimated_cost: (total_chars * target_locales.size / 1000.0) * 0.02,
|
73
|
+
currency: "USD"
|
74
|
+
}
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def should_auto_translate?
|
80
|
+
Rails.env.production? && ENV["AUTO_TRANSLATE"] == "true"
|
81
|
+
end
|
82
|
+
|
83
|
+
def retranslate_if_changed
|
84
|
+
changed_fields = translatable_fields.select do |field|
|
85
|
+
saved_change_to_attribute?("#{field}_#{I18n.default_locale}")
|
86
|
+
end
|
87
|
+
|
88
|
+
return if changed_fields.empty?
|
89
|
+
|
90
|
+
TranslationJob.perform_later(self, changed_fields)
|
91
|
+
end
|
92
|
+
|
93
|
+
def translatable_fields
|
94
|
+
%i[title content description]
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# === EXEMPLE 2: Modèle avec attributs I18n simples ===
|
99
|
+
|
100
|
+
class Product < ApplicationRecord
|
101
|
+
# Colonnes: name_fr, name_en, description_fr, description_en, etc.
|
102
|
+
|
103
|
+
include MistralTranslator::Helpers::RecordHelpers
|
104
|
+
|
105
|
+
SUPPORTED_LOCALES = %w[fr en es de].freeze
|
106
|
+
TRANSLATABLE_FIELDS = %w[name description features].freeze
|
107
|
+
|
108
|
+
def translate_all!(source_locale: "fr")
|
109
|
+
target_locales = SUPPORTED_LOCALES - [source_locale]
|
110
|
+
|
111
|
+
TRANSLATABLE_FIELDS.each do |field|
|
112
|
+
source_text = public_send("#{field}_#{source_locale}")
|
113
|
+
next if source_text.blank?
|
114
|
+
|
115
|
+
target_locales.each do |target_locale|
|
116
|
+
translated = MistralTranslator.translate(
|
117
|
+
source_text,
|
118
|
+
from: source_locale,
|
119
|
+
to: target_locale,
|
120
|
+
context: "e-commerce product",
|
121
|
+
glossary: product_glossary
|
122
|
+
)
|
123
|
+
|
124
|
+
public_send("#{field}_#{target_locale}=", translated)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
save!
|
129
|
+
end
|
130
|
+
|
131
|
+
def translate_field(field, from:, to:)
|
132
|
+
source_text = public_send("#{field}_#{from}")
|
133
|
+
return if source_text.blank?
|
134
|
+
|
135
|
+
translated = MistralTranslator.translate(
|
136
|
+
source_text,
|
137
|
+
from: from,
|
138
|
+
to: to,
|
139
|
+
context: "product #{field}",
|
140
|
+
glossary: product_glossary
|
141
|
+
)
|
142
|
+
|
143
|
+
update!("#{field}_#{to}" => translated)
|
144
|
+
translated
|
145
|
+
end
|
146
|
+
|
147
|
+
def missing_translations
|
148
|
+
missing = {}
|
149
|
+
|
150
|
+
TRANSLATABLE_FIELDS.each do |field|
|
151
|
+
SUPPORTED_LOCALES.each do |locale|
|
152
|
+
if public_send("#{field}_#{locale}").blank?
|
153
|
+
missing[field] ||= []
|
154
|
+
missing[field] << locale
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
missing
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
def product_glossary
|
165
|
+
{
|
166
|
+
"premium" => "premium",
|
167
|
+
"pro" => "pro",
|
168
|
+
"standard" => "standard",
|
169
|
+
brand => brand # Garder le nom de marque
|
170
|
+
}
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# === EXEMPLE 3: Service Object pour traductions en masse ===
|
175
|
+
|
176
|
+
class BulkTranslationService
|
177
|
+
def initialize(model_class, field_names, options = {})
|
178
|
+
@model_class = model_class
|
179
|
+
@field_names = Array(field_names)
|
180
|
+
@source_locale = options[:source_locale] || "fr"
|
181
|
+
@target_locales = options[:target_locales] || %w[en es de]
|
182
|
+
@batch_size = options[:batch_size] || 10
|
183
|
+
@context = options[:context]
|
184
|
+
end
|
185
|
+
|
186
|
+
def translate_all!
|
187
|
+
@model_class.find_in_batches(batch_size: @batch_size) do |batch|
|
188
|
+
translate_batch!(batch)
|
189
|
+
sleep(2) # Rate limiting
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def translate_missing!
|
194
|
+
records_with_missing = @model_class.joins(@target_locales.map do |locale|
|
195
|
+
"LEFT JOIN #{@model_class.table_name} as #{locale}_table ON #{locale}_table.id = #{@model_class.table_name}.id"
|
196
|
+
end.join(" ")).where(
|
197
|
+
@target_locales.map do |locale|
|
198
|
+
@field_names.map { |field| "#{field}_#{locale} IS NULL OR #{field}_#{locale} = ''" }
|
199
|
+
end.flatten.join(" OR ")
|
200
|
+
)
|
201
|
+
|
202
|
+
records_with_missing.find_in_batches(batch_size: @batch_size) do |batch|
|
203
|
+
translate_batch!(batch)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
private
|
208
|
+
|
209
|
+
def translate_batch!(records)
|
210
|
+
records.each do |record|
|
211
|
+
@field_names.each do |field|
|
212
|
+
source_text = record.public_send("#{field}_#{@source_locale}")
|
213
|
+
next if source_text.blank?
|
214
|
+
|
215
|
+
@target_locales.each do |target_locale|
|
216
|
+
next unless record.public_send("#{field}_#{target_locale}").blank?
|
217
|
+
|
218
|
+
begin
|
219
|
+
translated = MistralTranslator.translate(
|
220
|
+
source_text,
|
221
|
+
from: @source_locale,
|
222
|
+
to: target_locale,
|
223
|
+
context: @context
|
224
|
+
)
|
225
|
+
|
226
|
+
record.update_column("#{field}_#{target_locale}", translated)
|
227
|
+
Rails.logger.info "✅ Translated #{@model_class.name}##{record.id} #{field} to #{target_locale}"
|
228
|
+
rescue MistralTranslator::Error => e
|
229
|
+
Rails.logger.error "❌ Failed to translate #{@model_class.name}##{record.id}: #{e.message}"
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# === EXEMPLE 4: Job Sidekiq pour traductions asynchrones ===
|
238
|
+
|
239
|
+
class TranslationJob < ApplicationJob
|
240
|
+
queue_as :translations
|
241
|
+
retry_on MistralTranslator::RateLimitError, wait: :exponentially_longer
|
242
|
+
discard_on MistralTranslator::AuthenticationError
|
243
|
+
|
244
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
245
|
+
def perform(record, field_names, options = {})
|
246
|
+
source_locale = options["source_locale"] || I18n.default_locale.to_s
|
247
|
+
target_locales = options["target_locales"] || (I18n.available_locales.map(&:to_s) - [source_locale])
|
248
|
+
context = options["context"] || "#{record.class.name.downcase} content"
|
249
|
+
|
250
|
+
Array(field_names).each do |field|
|
251
|
+
source_text = record.public_send("#{field}_#{source_locale}")
|
252
|
+
next if source_text.blank?
|
253
|
+
|
254
|
+
target_locales.each do |target_locale|
|
255
|
+
translated = MistralTranslator.translate(
|
256
|
+
source_text,
|
257
|
+
from: source_locale,
|
258
|
+
to: target_locale,
|
259
|
+
context: context
|
260
|
+
)
|
261
|
+
|
262
|
+
record.update_column("#{field}_#{target_locale}", translated)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Callback optionnel
|
267
|
+
record.after_translation_complete if record.respond_to?(:after_translation_complete)
|
268
|
+
end
|
269
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
270
|
+
end
|
271
|
+
|
272
|
+
# === EXEMPLE 5: Concern réutilisable ===
|
273
|
+
|
274
|
+
module Translatable
|
275
|
+
extend ActiveSupport::Concern
|
276
|
+
|
277
|
+
included do
|
278
|
+
scope :with_missing_translations, lambda { |locale|
|
279
|
+
where(translatable_fields.map { |field| "#{field}_#{locale} IS NULL OR #{field}_#{locale} = ''" }.join(" OR "))
|
280
|
+
}
|
281
|
+
|
282
|
+
scope :translated_in, lambda { |locale|
|
283
|
+
where.not(translatable_fields.map do |field|
|
284
|
+
"#{field}_#{locale} IS NULL OR #{field}_#{locale} = ''"
|
285
|
+
end.join(" OR "))
|
286
|
+
}
|
287
|
+
end
|
288
|
+
|
289
|
+
class_methods do
|
290
|
+
def translatable_fields(*fields)
|
291
|
+
if fields.any?
|
292
|
+
@translatable_fields = fields
|
293
|
+
else
|
294
|
+
@translatable_fields || []
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def supported_locales(*locales)
|
299
|
+
if locales.any?
|
300
|
+
@supported_locales = locales.map(&:to_s)
|
301
|
+
else
|
302
|
+
@supported_locales || I18n.available_locales.map(&:to_s)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
def bulk_translate!(source_locale: "fr", target_locales: nil, context: nil)
|
307
|
+
target_locales ||= supported_locales - [source_locale.to_s]
|
308
|
+
|
309
|
+
BulkTranslationService.new(
|
310
|
+
self,
|
311
|
+
translatable_fields,
|
312
|
+
source_locale: source_locale,
|
313
|
+
target_locales: target_locales,
|
314
|
+
context: context || name.downcase
|
315
|
+
).translate_missing!
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def translate_async!(source_locale: I18n.locale, target_locales: nil, context: nil)
|
320
|
+
target_locales ||= self.class.supported_locales - [source_locale.to_s]
|
321
|
+
|
322
|
+
TranslationJob.perform_later(
|
323
|
+
self,
|
324
|
+
self.class.translatable_fields,
|
325
|
+
"source_locale" => source_locale.to_s,
|
326
|
+
"target_locales" => target_locales,
|
327
|
+
"context" => context
|
328
|
+
)
|
329
|
+
end
|
330
|
+
|
331
|
+
def translation_progress
|
332
|
+
total_combinations = self.class.translatable_fields.size * self.class.supported_locales.size
|
333
|
+
completed = 0
|
334
|
+
|
335
|
+
self.class.translatable_fields.each do |field|
|
336
|
+
self.class.supported_locales.each do |locale|
|
337
|
+
completed += 1 unless public_send("#{field}_#{locale}").blank?
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
(completed.to_f / total_combinations * 100).round(1)
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
# === UTILISATION ===
|
346
|
+
|
347
|
+
puts "=== Exemples Rails Models avec MistralTranslator ==="
|
348
|
+
|
349
|
+
# Utilisation du concern
|
350
|
+
class BlogPost < ApplicationRecord
|
351
|
+
include Translatable
|
352
|
+
|
353
|
+
translatable_fields :title, :content, :summary
|
354
|
+
supported_locales :fr, :en, :es, :de
|
355
|
+
end
|
356
|
+
|
357
|
+
# Exemples d'utilisation
|
358
|
+
if defined?(Rails) && Rails.env.development?
|
359
|
+
|
360
|
+
# Test avec un article
|
361
|
+
article = Article.create!(
|
362
|
+
title_fr: "Les avantages de Ruby on Rails",
|
363
|
+
content_fr: "Ruby on Rails est un framework...",
|
364
|
+
description_fr: "Guide complet sur Rails"
|
365
|
+
)
|
366
|
+
|
367
|
+
puts "Article créé: #{article.title_fr}"
|
368
|
+
|
369
|
+
# Traduction manuelle
|
370
|
+
article.translate_to(%i[en es])
|
371
|
+
puts "Traduit en: #{article.title_en}, #{article.title_es}"
|
372
|
+
|
373
|
+
# Estimation des coûts
|
374
|
+
cost = article.estimate_translation_cost
|
375
|
+
puts "Coût estimé: $#{cost[:estimated_cost]} pour #{cost[:target_languages]} langues"
|
376
|
+
|
377
|
+
# Traduction en masse
|
378
|
+
puts "\nTraduction en masse des articles..."
|
379
|
+
BulkTranslationService.new(
|
380
|
+
Article,
|
381
|
+
%i[title content],
|
382
|
+
source_locale: "fr",
|
383
|
+
target_locales: %w[en es],
|
384
|
+
context: "blog articles"
|
385
|
+
).translate_missing!
|
386
|
+
|
387
|
+
# Utilisation du concern
|
388
|
+
post = BlogPost.create!(
|
389
|
+
title_fr: "Nouveau post",
|
390
|
+
content_fr: "Contenu du post..."
|
391
|
+
)
|
392
|
+
|
393
|
+
puts "Progress: #{post.translation_progress}%"
|
394
|
+
post.translate_async!(target_locales: %w[en es])
|
395
|
+
puts "Job de traduction lancé"
|
396
|
+
|
397
|
+
end
|
398
|
+
|
399
|
+
puts "Exemples terminés !"
|
@@ -0,0 +1,261 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MistralTranslator
|
4
|
+
module Adapters
|
5
|
+
# Interface de base pour les adaptateurs
|
6
|
+
class BaseAdapter
|
7
|
+
def initialize(record)
|
8
|
+
@record = record
|
9
|
+
end
|
10
|
+
|
11
|
+
# Méthodes à implémenter par les adaptateurs spécifiques
|
12
|
+
def available_locales
|
13
|
+
raise NotImplementedError, "Subclass must implement #available_locales"
|
14
|
+
end
|
15
|
+
|
16
|
+
def get_field_value(field, locale)
|
17
|
+
raise NotImplementedError, "Subclass must implement #get_field_value"
|
18
|
+
end
|
19
|
+
|
20
|
+
def set_field_value(field, locale, value)
|
21
|
+
raise NotImplementedError, "Subclass must implement #set_field_value"
|
22
|
+
end
|
23
|
+
|
24
|
+
def save_record!
|
25
|
+
@record.save!
|
26
|
+
end
|
27
|
+
|
28
|
+
# Méthodes utilitaires communes
|
29
|
+
def detect_source_locale(field, preferred_locale = nil)
|
30
|
+
# Priorité à une locale source fournie
|
31
|
+
return preferred_locale.to_sym if preferred_locale && content?(field, preferred_locale)
|
32
|
+
|
33
|
+
# Chercher quelle locale a du contenu pour ce champ
|
34
|
+
available_locales.each do |locale|
|
35
|
+
return locale if content?(field, locale)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Fallback sur la première locale disponible
|
39
|
+
available_locales.first
|
40
|
+
end
|
41
|
+
|
42
|
+
def content?(field, locale)
|
43
|
+
value = get_field_value(field, locale)
|
44
|
+
return false if value.nil?
|
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
|
52
|
+
end
|
53
|
+
|
54
|
+
def get_translatable_content(field, locale)
|
55
|
+
value = get_field_value(field, locale)
|
56
|
+
return nil if value.nil?
|
57
|
+
|
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
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Adaptateur pour Mobility
|
68
|
+
class MobilityAdapter < BaseAdapter
|
69
|
+
def available_locales
|
70
|
+
I18n.available_locales
|
71
|
+
end
|
72
|
+
|
73
|
+
def get_field_value(field, locale)
|
74
|
+
@record.public_send("#{field}_#{locale}")
|
75
|
+
rescue NoMethodError
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
|
79
|
+
def set_field_value(field, locale, value)
|
80
|
+
@record.public_send("#{field}_#{locale}=", value)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Adaptateur pour les attributs I18n standards avec suffixes
|
85
|
+
class I18nAttributesAdapter < BaseAdapter
|
86
|
+
def available_locales
|
87
|
+
I18n.available_locales
|
88
|
+
end
|
89
|
+
|
90
|
+
def get_field_value(field, locale)
|
91
|
+
@record.public_send("#{field}_#{locale}")
|
92
|
+
rescue NoMethodError
|
93
|
+
nil
|
94
|
+
end
|
95
|
+
|
96
|
+
def set_field_value(field, locale, value)
|
97
|
+
@record.public_send("#{field}_#{locale}=", value)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Adaptateur pour Globalize
|
102
|
+
class GlobalizeAdapter < BaseAdapter
|
103
|
+
def available_locales
|
104
|
+
@record.class.translated_locales || I18n.available_locales
|
105
|
+
end
|
106
|
+
|
107
|
+
def get_field_value(field, locale)
|
108
|
+
I18n.with_locale(locale) do
|
109
|
+
@record.public_send(field)
|
110
|
+
end
|
111
|
+
rescue NoMethodError
|
112
|
+
nil
|
113
|
+
end
|
114
|
+
|
115
|
+
def set_field_value(field, locale, value)
|
116
|
+
I18n.with_locale(locale) do
|
117
|
+
@record.public_send("#{field}=", value)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Adaptateur pour des méthodes custom
|
123
|
+
class CustomAdapter < BaseAdapter
|
124
|
+
def initialize(record, options = {})
|
125
|
+
super(record)
|
126
|
+
@get_method = options[:get_method] || :get_translation
|
127
|
+
@set_method = options[:set_method] || :set_translation
|
128
|
+
@locales_method = options[:locales_method] || :available_locales
|
129
|
+
end
|
130
|
+
|
131
|
+
def available_locales
|
132
|
+
if @record.respond_to?(@locales_method)
|
133
|
+
@record.public_send(@locales_method)
|
134
|
+
else
|
135
|
+
I18n.available_locales
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def get_field_value(field, locale)
|
140
|
+
@record.public_send(@get_method, field, locale)
|
141
|
+
rescue NoMethodError
|
142
|
+
nil
|
143
|
+
end
|
144
|
+
|
145
|
+
def set_field_value(field, locale, value)
|
146
|
+
@record.public_send(@set_method, field, locale, value)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Factory pour détecter automatiquement l'adaptateur approprié
|
151
|
+
class AdapterFactory
|
152
|
+
def self.build_for(record)
|
153
|
+
# Détecter Mobility
|
154
|
+
return MobilityAdapter.new(record) if defined?(Mobility) && record.class.respond_to?(:mobility_attributes)
|
155
|
+
|
156
|
+
# Détecter Globalize
|
157
|
+
if defined?(Globalize) && record.class.respond_to?(:translated_attribute_names)
|
158
|
+
return GlobalizeAdapter.new(record)
|
159
|
+
end
|
160
|
+
|
161
|
+
# Par défaut, essayer l'adaptateur I18n avec suffixes
|
162
|
+
I18nAttributesAdapter.new(record)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
# Service de traduction utilisant les adaptateurs
|
168
|
+
module Adapters
|
169
|
+
class RecordTranslationService
|
170
|
+
def initialize(record, translatable_fields, adapter: nil, source_locale: nil)
|
171
|
+
@record = record
|
172
|
+
@translatable_fields = Array(translatable_fields)
|
173
|
+
@adapter = adapter || Adapters::AdapterFactory.build_for(record)
|
174
|
+
@source_locale = source_locale
|
175
|
+
end
|
176
|
+
|
177
|
+
def translate_to_all_locales
|
178
|
+
return false if @translatable_fields.empty?
|
179
|
+
|
180
|
+
ActiveRecord::Base.transaction do
|
181
|
+
@translatable_fields.each do |field|
|
182
|
+
translate_field(field)
|
183
|
+
end
|
184
|
+
@adapter.save_record!
|
185
|
+
end
|
186
|
+
|
187
|
+
true
|
188
|
+
rescue StandardError => e
|
189
|
+
Rails.logger.error "Translation failed: #{e.message}" if defined?(Rails)
|
190
|
+
false
|
191
|
+
end
|
192
|
+
|
193
|
+
private
|
194
|
+
|
195
|
+
def translate_field(field)
|
196
|
+
source_locale = @adapter.detect_source_locale(field, @source_locale)
|
197
|
+
source_content = @adapter.get_translatable_content(field, source_locale)
|
198
|
+
|
199
|
+
return if source_content.nil? || source_content.strip.empty?
|
200
|
+
|
201
|
+
target_locales = @adapter.available_locales - [source_locale]
|
202
|
+
|
203
|
+
target_locales.each do |target_locale|
|
204
|
+
translate_to_locale(field, source_content, source_locale, target_locale)
|
205
|
+
sleep(2) # Rate limiting basique
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
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
|
+
)
|
215
|
+
|
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}"
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# Méthodes de convenance pour utilisation directe
|
233
|
+
module RecordTranslation
|
234
|
+
def self.translate_record(record, fields, adapter: nil, source_locale: nil)
|
235
|
+
service = Adapters::RecordTranslationService.new(record, fields, adapter: adapter, source_locale: source_locale)
|
236
|
+
service.translate_to_all_locales
|
237
|
+
end
|
238
|
+
|
239
|
+
# Pour Mobility (exemple d'usage)
|
240
|
+
def self.translate_mobility_record(record, fields, source_locale: nil)
|
241
|
+
adapter = Adapters::MobilityAdapter.new(record)
|
242
|
+
translate_record(record, fields, adapter: adapter, source_locale: source_locale)
|
243
|
+
end
|
244
|
+
|
245
|
+
# Pour Globalize (exemple d'usage)
|
246
|
+
def self.translate_globalize_record(record, fields, source_locale: nil)
|
247
|
+
adapter = Adapters::GlobalizeAdapter.new(record)
|
248
|
+
translate_record(record, fields, adapter: adapter, source_locale: source_locale)
|
249
|
+
end
|
250
|
+
|
251
|
+
# Pour des méthodes custom
|
252
|
+
def self.translate_custom_record(record, fields, get_method:, set_method:, **options)
|
253
|
+
adapter = Adapters::CustomAdapter.new(record, {
|
254
|
+
get_method: get_method,
|
255
|
+
set_method: set_method,
|
256
|
+
locales_method: options[:locales_method]
|
257
|
+
})
|
258
|
+
translate_record(record, fields, adapter: adapter, source_locale: options[:source_locale])
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|