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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -1
- data/README.md +14 -2
- data/docs/api-reference/configuration.md +44 -4
- data/docs/api-reference/errors.md +15 -24
- data/docs/api-reference/methods.md +10 -4
- data/docs/getting-started.md +10 -2
- data/docs/installation.md +0 -2
- data/docs/rails-integration/setup.md +0 -1
- data/lib/mistral_translator/client.rb +18 -19
- data/lib/mistral_translator/client_helpers.rb +41 -23
- data/lib/mistral_translator/configuration.rb +34 -4
- data/lib/mistral_translator/data/languages.json +912 -0
- data/lib/mistral_translator/deprecated_options.rb +45 -0
- data/lib/mistral_translator/helpers.rb +15 -17
- data/lib/mistral_translator/helpers_extensions.rb +1 -59
- data/lib/mistral_translator/locale_helper.rb +19 -31
- data/lib/mistral_translator/prompt.rb +48 -0
- data/lib/mistral_translator/prompt_builder.rb +30 -144
- data/lib/mistral_translator/prompt_helpers.rb +21 -23
- data/lib/mistral_translator/prompt_metadata_helpers.rb +15 -17
- data/lib/mistral_translator/proxy.rb +75 -0
- data/lib/mistral_translator/response_parser.rb +138 -215
- data/lib/mistral_translator/retries.rb +50 -0
- data/lib/mistral_translator/summarizer.rb +33 -146
- data/lib/mistral_translator/translator.rb +33 -95
- data/lib/mistral_translator/translator_helpers.rb +21 -149
- data/lib/mistral_translator/version.rb +1 -1
- data/lib/mistral_translator.rb +1 -1
- metadata +6 -1
|
@@ -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 =
|
|
10
|
-
DEFAULT_RETRY_DELAY =
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
prompt
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
#
|
|
121
|
-
def
|
|
122
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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 =
|
|
18
|
-
DEFAULT_RETRY_DELAY =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
178
|
-
prompt =
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
66
|
+
raise InvalidResponseError.new("Language detection failed", response)
|
|
163
67
|
rescue JSON::ParserError
|
|
164
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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 = {}
|