mistral_translator 0.3.2 → 0.4.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 +42 -0
- data/README.md +9 -2
- data/docs/api-reference/configuration.md +19 -2
- data/docs/api-reference/errors.md +15 -24
- data/docs/api-reference/methods.md +10 -4
- data/docs/getting-started.md +8 -2
- data/lib/mistral_translator/client.rb +18 -19
- data/lib/mistral_translator/client_helpers.rb +39 -21
- data/lib/mistral_translator/configuration.rb +11 -1
- data/lib/mistral_translator/data/languages.json +912 -0
- data/lib/mistral_translator/helpers.rb +16 -13
- data/lib/mistral_translator/helpers_extensions.rb +1 -1
- data/lib/mistral_translator/locale_helper.rb +19 -31
- data/lib/mistral_translator/prompt.rb +48 -0
- data/lib/mistral_translator/prompt_builder.rb +40 -60
- data/lib/mistral_translator/prompt_helpers.rb +25 -4
- data/lib/mistral_translator/prompt_metadata_helpers.rb +15 -12
- data/lib/mistral_translator/proxy.rb +75 -0
- data/lib/mistral_translator/response_parser.rb +120 -140
- data/lib/mistral_translator/summarizer.rb +33 -109
- data/lib/mistral_translator/translator.rb +24 -72
- data/lib/mistral_translator/translator_helpers.rb +21 -60
- data/lib/mistral_translator/version.rb +1 -1
- data/lib/mistral_translator.rb +1 -1
- metadata +4 -1
|
@@ -4,47 +4,75 @@ require_relative "logger"
|
|
|
4
4
|
|
|
5
5
|
module MistralTranslator
|
|
6
6
|
class ResponseParser
|
|
7
|
+
# Emplacements possibles du texte traduit ou résumé, par ordre de priorité
|
|
8
|
+
TARGET_PATHS = [
|
|
9
|
+
%w[content target], %w[translation target], %w[target],
|
|
10
|
+
%w[content translated], %w[translated],
|
|
11
|
+
%w[content summary], %w[summary]
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
SOURCE_PATHS = [
|
|
15
|
+
%w[content source], %w[translation source], %w[source],
|
|
16
|
+
%w[content original], %w[original]
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
7
19
|
class << self
|
|
8
20
|
def parse_translation_response(raw_content)
|
|
9
21
|
return nil if raw_content.nil? || raw_content.empty?
|
|
10
22
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
translated_text = extract_target_content(translation_data)
|
|
17
|
-
validate_translation_content(translated_text)
|
|
18
|
-
|
|
19
|
-
build_translation_result(translation_data, translated_text)
|
|
20
|
-
rescue JSON::ParserError => e
|
|
21
|
-
handle_json_parse_error(e, raw_content, json_content)
|
|
22
|
-
rescue EmptyTranslationError
|
|
23
|
-
raise # Re-raise EmptyTranslationError
|
|
24
|
-
rescue StandardError => e
|
|
25
|
-
raise InvalidResponseError, "Error processing response: #{e.message}"
|
|
23
|
+
with_response_errors("translation", raw_content) do
|
|
24
|
+
data = parse_response_json(raw_content, "translation")
|
|
25
|
+
translated_text = target_text!(data, "Empty translation received from API", raw_content)
|
|
26
|
+
|
|
27
|
+
build_translation_result(data, translated_text)
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def parse_quality_check_response(raw_content)
|
|
30
32
|
return { translation: nil, quality_check: {}, metadata: {} } if raw_content.nil? || raw_content.empty?
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
with_response_errors("quality check", raw_content) do
|
|
35
|
+
data = parse_response_json(raw_content, "quality check")
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
{
|
|
38
|
+
translation: target_text!(data, "Empty translation received from API", raw_content),
|
|
39
|
+
quality_check: data["quality_check"] || value_at(data, %w[metadata quality_check]) || {},
|
|
40
|
+
metadata: metadata_of(data)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
def parse_summary_response(raw_content)
|
|
46
|
+
return nil if raw_content.nil? || raw_content.empty?
|
|
47
|
+
|
|
48
|
+
with_response_errors("summary", raw_content) do
|
|
49
|
+
data = parse_response_json(raw_content, "summary")
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
original: extract_source_content(data),
|
|
53
|
+
summary: target_text!(data, "Empty summary received", raw_content),
|
|
54
|
+
metadata: metadata_of(data)
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_bulk_translation_response(raw_content)
|
|
60
|
+
return [] if raw_content.nil? || raw_content.empty?
|
|
61
|
+
|
|
62
|
+
with_response_errors("bulk", raw_content) do
|
|
63
|
+
translations = parse_response_json(raw_content, "bulk")["translations"]
|
|
64
|
+
unless translations.is_a?(Array)
|
|
65
|
+
raise InvalidResponseError.new("No translations array in response", raw_content)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
translations.map do |translation|
|
|
69
|
+
{
|
|
70
|
+
index: translation["index"],
|
|
71
|
+
original: translation["source"],
|
|
72
|
+
translated: translation["target"]
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
48
76
|
end
|
|
49
77
|
|
|
50
78
|
def parse_json_content(json_content)
|
|
@@ -62,112 +90,41 @@ module MistralTranslator
|
|
|
62
90
|
end
|
|
63
91
|
end
|
|
64
92
|
|
|
65
|
-
def validate_translation_content(translated_text)
|
|
66
|
-
return unless translated_text.nil? || translated_text.empty?
|
|
67
|
-
|
|
68
|
-
raise EmptyTranslationError, "Empty translation received from API"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
93
|
def build_translation_result(translation_data, translated_text)
|
|
72
94
|
{
|
|
73
95
|
original: extract_source_content(translation_data),
|
|
74
96
|
translated: translated_text,
|
|
75
|
-
metadata: translation_data
|
|
76
|
-
}
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def handle_json_parse_error(error, raw_content, json_content)
|
|
80
|
-
error_details = {
|
|
81
|
-
error_message: error.message,
|
|
82
|
-
raw_content_length: raw_content&.length,
|
|
83
|
-
json_content_length: json_content&.length,
|
|
84
|
-
has_json_content: !json_content.nil?
|
|
97
|
+
metadata: metadata_of(translation_data)
|
|
85
98
|
}
|
|
86
|
-
Logger.debug_if_verbose(
|
|
87
|
-
"JSON parse failed: #{error.message} raw_len=#{error_details[:raw_content_length]} " \
|
|
88
|
-
"json_len=#{error_details[:json_content_length]} snippet=#{raw_content&.slice(0, 120)}",
|
|
89
|
-
sensitive: false
|
|
90
|
-
)
|
|
91
|
-
raise InvalidResponseError, "Invalid JSON in response: #{error.message}. Details: #{error_details}"
|
|
92
99
|
end
|
|
93
100
|
|
|
94
|
-
|
|
95
|
-
def parse_summary_response(raw_content)
|
|
96
|
-
return nil if raw_content.nil? || raw_content.empty?
|
|
97
|
-
|
|
98
|
-
begin
|
|
99
|
-
json_content = extract_json_from_content(raw_content)
|
|
100
|
-
unless json_content
|
|
101
|
-
# Aucun JSON détecté: utiliser le texte brut comme repli si non vide
|
|
102
|
-
text = raw_content.to_s.strip
|
|
103
|
-
raise EmptyTranslationError, "Empty summary received" if text.empty?
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
original: nil,
|
|
107
|
-
summary: text,
|
|
108
|
-
metadata: { "operation" => "summarization", "fallback" => true }
|
|
109
|
-
}
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
summary_data = JSON.parse(json_content)
|
|
113
|
-
summary_text = extract_target_content(summary_data)
|
|
114
|
-
|
|
115
|
-
raise EmptyTranslationError, "Empty summary received" if summary_text.nil? || summary_text.empty?
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
original: extract_source_content(summary_data),
|
|
119
|
-
summary: summary_text,
|
|
120
|
-
metadata: summary_data["metadata"] || {}
|
|
121
|
-
}
|
|
122
|
-
rescue JSON::ParserError
|
|
123
|
-
# Fallback: si ce n'est pas du JSON, essayer d'utiliser le texte brut s'il a du contenu
|
|
124
|
-
text = raw_content.to_s.strip
|
|
125
|
-
raise InvalidResponseError, "Invalid JSON in summary response: #{raw_content}" if text.empty?
|
|
101
|
+
private
|
|
126
102
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
end
|
|
103
|
+
# Les erreurs Ruby (JSON::ParserError, NoMethodError, TypeError…) citent la réponse dans
|
|
104
|
+
# leur message : ni ce message ni l'erreur d'origine (cause) ne sont repris.
|
|
105
|
+
# La réponse reste accessible au code appelant via InvalidResponseError#raw_response.
|
|
106
|
+
def with_response_errors(label, raw_content)
|
|
107
|
+
yield
|
|
108
|
+
rescue EmptyTranslationError, InvalidResponseError
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
raise InvalidResponseError.new("Error processing #{label} response (#{e.class})", raw_content), cause: nil
|
|
137
112
|
end
|
|
138
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
139
113
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
begin
|
|
144
|
-
json_content = extract_json_from_content(raw_content)
|
|
145
|
-
raise InvalidResponseError, "Invalid JSON in bulk response: #{raw_content}" unless json_content
|
|
146
|
-
|
|
147
|
-
bulk_data = JSON.parse(json_content)
|
|
148
|
-
translations = bulk_data["translations"]
|
|
149
|
-
|
|
150
|
-
raise InvalidResponseError, "No translations array in response" unless translations.is_a?(Array)
|
|
114
|
+
def parse_response_json(raw_content, label)
|
|
115
|
+
json_content = extract_json_from_content(raw_content)
|
|
116
|
+
raise InvalidResponseError.new("No JSON in #{label} response", raw_content) unless json_content
|
|
151
117
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
index: translation["index"],
|
|
155
|
-
original: translation["source"],
|
|
156
|
-
translated: translation["target"]
|
|
157
|
-
}
|
|
158
|
-
end
|
|
159
|
-
rescue JSON::ParserError
|
|
160
|
-
raise InvalidResponseError, "Invalid JSON in bulk response: #{raw_content}"
|
|
161
|
-
rescue StandardError => e
|
|
162
|
-
# Ne pas wrapper l'erreur "No translations array in response"
|
|
163
|
-
raise e if e.message == "No translations array in response"
|
|
118
|
+
data = parse_json_content(json_content)
|
|
119
|
+
raise InvalidResponseError.new("Unexpected JSON in #{label} response", raw_content) unless data.is_a?(Hash)
|
|
164
120
|
|
|
165
|
-
|
|
166
|
-
|
|
121
|
+
data
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
details = "raw_len=#{raw_content.length} json_len=#{json_content&.length}"
|
|
124
|
+
Logger.debug_if_verbose("JSON parse failed: #{details}", sensitive: false)
|
|
125
|
+
raise InvalidResponseError.new("Invalid JSON in #{label} response (#{details})", raw_content), cause: nil
|
|
167
126
|
end
|
|
168
127
|
|
|
169
|
-
private
|
|
170
|
-
|
|
171
128
|
def extract_json_from_content(content)
|
|
172
129
|
return nil if content.nil? || content.empty?
|
|
173
130
|
|
|
@@ -268,28 +225,51 @@ module MistralTranslator
|
|
|
268
225
|
end
|
|
269
226
|
end
|
|
270
227
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
228
|
+
# Le texte traduit ou résumé : toujours une String non vide, jamais un objet ou un tableau.
|
|
229
|
+
def target_text!(data, empty_message, raw_content)
|
|
230
|
+
target = find_target(data)
|
|
231
|
+
nested = nested_response(target)
|
|
232
|
+
target = find_target(nested) || "" if nested && target_candidates(nested).any?
|
|
233
|
+
|
|
234
|
+
unless target.nil? || target.is_a?(String)
|
|
235
|
+
raise InvalidResponseError.new("Unexpected target in response", raw_content)
|
|
236
|
+
end
|
|
237
|
+
raise EmptyTranslationError, empty_message if target.to_s.strip.empty?
|
|
238
|
+
|
|
239
|
+
target
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def find_target(data)
|
|
243
|
+
target_candidates(data).find { |item| !(item.respond_to?(:empty?) && item.empty?) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def target_candidates(data)
|
|
247
|
+
TARGET_PATHS.filter_map { |path| value_at(data, path) }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Comme Hash#dig, mais un nœud intermédiaire qui n'est pas un objet donne nil au lieu de TypeError.
|
|
251
|
+
def value_at(data, path)
|
|
252
|
+
path.reduce(data) { |node, key| node.is_a?(Hash) ? node[key] : nil }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def metadata_of(data)
|
|
256
|
+
data["metadata"].is_a?(Hash) ? data["metadata"] : {}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Le modèle renvoie parfois l'objet de réponse attendu, tel quel ou sérialisé, dans "target" :
|
|
260
|
+
# on y cherche la cible un niveau plus bas. Un JSON sans clé connue reste une traduction.
|
|
261
|
+
def nested_response(target)
|
|
262
|
+
return target if target.is_a?(Hash)
|
|
263
|
+
return nil unless target.is_a?(String) && target.lstrip.start_with?("{")
|
|
264
|
+
|
|
265
|
+
parsed = JSON.parse(target)
|
|
266
|
+
parsed if parsed.is_a?(Hash)
|
|
267
|
+
rescue JSON::ParserError
|
|
268
|
+
nil
|
|
282
269
|
end
|
|
283
270
|
|
|
284
271
|
def extract_source_content(data)
|
|
285
|
-
|
|
286
|
-
[
|
|
287
|
-
data.dig("content", "source"),
|
|
288
|
-
data.dig("translation", "source"),
|
|
289
|
-
data["source"],
|
|
290
|
-
data.dig("content", "original"),
|
|
291
|
-
data["original"]
|
|
292
|
-
].find { |item| item && !item.to_s.empty? }
|
|
272
|
+
SOURCE_PATHS.map { |path| value_at(data, path) }.find { |item| item.is_a?(String) && !item.empty? }
|
|
293
273
|
end
|
|
294
274
|
end
|
|
295
275
|
end
|
|
@@ -15,7 +15,7 @@ module MistralTranslator
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# Résumé simple dans une langue donnée
|
|
18
|
-
def summarize(text, language: "fr", max_words: DEFAULT_MAX_WORDS)
|
|
18
|
+
def summarize(text, language: "fr", max_words: DEFAULT_MAX_WORDS, context: nil, style: nil)
|
|
19
19
|
log_debug("Starting summarize - language: #{language}, max_words: #{max_words}")
|
|
20
20
|
|
|
21
21
|
# Validation basique des entrées
|
|
@@ -26,18 +26,14 @@ module MistralTranslator
|
|
|
26
26
|
return "" if validated_text.empty?
|
|
27
27
|
|
|
28
28
|
target_locale = LocaleHelper.validate_locale!(language)
|
|
29
|
-
log_debug("Target locale validated: #{target_locale}")
|
|
30
|
-
|
|
31
29
|
cleaned_text = clean_document_content(validated_text)
|
|
32
|
-
log_debug("Text cleaned, length: #{cleaned_text&.length}")
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
result
|
|
31
|
+
prompt = PromptBuilder.summary_prompt(cleaned_text, max_words, target_locale, context: context, style: style)
|
|
32
|
+
request_summary(prompt, target_locale)
|
|
37
33
|
end
|
|
38
34
|
|
|
39
35
|
# Résumé avec traduction simultanée
|
|
40
|
-
def summarize_and_translate(text, from:, to:, max_words: DEFAULT_MAX_WORDS)
|
|
36
|
+
def summarize_and_translate(text, from:, to:, max_words: DEFAULT_MAX_WORDS, context: nil, style: nil) # rubocop:disable Metrics/ParameterLists
|
|
41
37
|
log_debug("Starting summarize_and_translate - from: #{from}, to: #{to}")
|
|
42
38
|
validate_summarize_translate_inputs!(text, from, to, max_words)
|
|
43
39
|
|
|
@@ -47,143 +43,69 @@ module MistralTranslator
|
|
|
47
43
|
|
|
48
44
|
# Si même langue, juste résumer
|
|
49
45
|
if source_locale == target_locale
|
|
50
|
-
|
|
51
|
-
return summarize(cleaned_text, language: target_locale, max_words: max_words)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Sinon, créer un prompt qui fait les deux à la fois
|
|
55
|
-
log_debug("Different languages, using combined summarize+translate")
|
|
56
|
-
prompt = build_summary_translation_prompt(cleaned_text, source_locale, target_locale, max_words)
|
|
57
|
-
raw_response = @client.complete(prompt)
|
|
58
|
-
|
|
59
|
-
result = ResponseParser.parse_summary_response(raw_response)
|
|
60
|
-
if result.nil? || result[:summary].nil? || result[:summary].empty?
|
|
61
|
-
# Fallback: si le JSON est manquant ou vide, on tente un résumé simple puis traduction
|
|
62
|
-
log_error(
|
|
63
|
-
"Empty or invalid summary response for summarize_and_translate. Using fallback. " \
|
|
64
|
-
"Raw length: #{raw_response.to_s.length}"
|
|
65
|
-
)
|
|
66
|
-
summary_only = summarize(cleaned_text, language: source_locale, max_words: max_words)
|
|
67
|
-
begin
|
|
68
|
-
return MistralTranslator.translate(summary_only, from: source_locale, to: target_locale)
|
|
69
|
-
rescue EmptyTranslationError, InvalidResponseError, RateLimitError => e
|
|
70
|
-
log_error("Translation fallback failed: #{e.class.name} - returning source summary")
|
|
71
|
-
return summary_only
|
|
72
|
-
end
|
|
46
|
+
return summarize(cleaned_text, language: target_locale, max_words: max_words, context: context, style: style)
|
|
73
47
|
end
|
|
74
48
|
|
|
75
|
-
|
|
49
|
+
prompt = PromptBuilder.summary_and_translation_prompt(cleaned_text, source_locale, target_locale, max_words,
|
|
50
|
+
context: context, style: style)
|
|
51
|
+
request_summary(prompt, target_locale)
|
|
76
52
|
end
|
|
77
53
|
|
|
78
54
|
# Résumé en plusieurs langues
|
|
79
|
-
def summarize_to_multiple(text, languages:, max_words: DEFAULT_MAX_WORDS)
|
|
55
|
+
def summarize_to_multiple(text, languages:, max_words: DEFAULT_MAX_WORDS, context: nil, style: nil)
|
|
80
56
|
log_debug("Starting summarize_to_multiple - languages: #{languages}")
|
|
81
57
|
validate_multiple_summarize_inputs!(text, languages, max_words)
|
|
82
58
|
|
|
83
59
|
target_locales = Array(languages).map { |locale| LocaleHelper.validate_locale!(locale) }
|
|
84
60
|
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
61
|
|
|
62
|
+
target_locales.each_with_index.to_h do |target_locale, index|
|
|
90
63
|
# 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
|
|
64
|
+
sleep(DEFAULT_RETRY_DELAY) if index.positive?
|
|
95
65
|
|
|
96
|
-
|
|
66
|
+
prompt = PromptBuilder.summary_prompt(cleaned_text, max_words, target_locale, context: context, style: style)
|
|
67
|
+
[target_locale, request_summary(prompt, target_locale)]
|
|
97
68
|
end
|
|
98
|
-
|
|
99
|
-
log_debug("Multiple summarization completed")
|
|
100
|
-
results
|
|
101
69
|
end
|
|
102
70
|
|
|
103
71
|
# Résumé par niveaux (court, moyen, long)
|
|
104
|
-
def summarize_tiered(text, language: "fr", short: 50, medium: 150, long: 300)
|
|
72
|
+
def summarize_tiered(text, language: "fr", short: 50, medium: 150, long: 300, context: nil, style: nil) # rubocop:disable Metrics/ParameterLists
|
|
105
73
|
log_debug("Starting summarize_tiered - short: #{short}, medium: #{medium}, long: #{long}")
|
|
106
74
|
validate_tiered_inputs!(text, language, short, medium, long)
|
|
107
75
|
|
|
108
76
|
target_locale = LocaleHelper.validate_locale!(language)
|
|
109
77
|
cleaned_text = clean_document_content(text)
|
|
110
78
|
|
|
111
|
-
{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
79
|
+
{ short: short, medium: medium, long: long }.transform_values do |max_words|
|
|
80
|
+
prompt = PromptBuilder.summary_prompt(cleaned_text, max_words, target_locale, context: context, style: style)
|
|
81
|
+
request_summary(prompt, target_locale)
|
|
82
|
+
end
|
|
116
83
|
end
|
|
117
84
|
|
|
118
85
|
private
|
|
119
86
|
|
|
120
|
-
#
|
|
121
|
-
def
|
|
122
|
-
log_debug("
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
87
|
+
# Relances communes à tous les résumés ; une réponse inexploitable finit par lever une erreur.
|
|
88
|
+
def request_summary(prompt, target_locale, attempt = 0)
|
|
89
|
+
log_debug("Summary attempt #{attempt + 1} for #{target_locale}")
|
|
90
|
+
|
|
91
|
+
result = ResponseParser.parse_summary_response(@client.complete(prompt, json: true))
|
|
92
|
+
raise EmptyTranslationError, "Empty summary received" if result.nil? || result[:summary].to_s.empty?
|
|
136
93
|
|
|
137
|
-
log_debug("Summary successful for #{target_locale}")
|
|
138
94
|
result[:summary]
|
|
139
95
|
rescue EmptyTranslationError, InvalidResponseError => e
|
|
140
|
-
if attempt
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
log_debug("Max retries reached for #{target_locale}, giving up")
|
|
147
|
-
raise e
|
|
148
|
-
end
|
|
96
|
+
raise if attempt >= DEFAULT_RETRY_COUNT
|
|
97
|
+
|
|
98
|
+
wait_time = DEFAULT_RETRY_DELAY * (2**attempt)
|
|
99
|
+
log_retry(e, attempt + 1, wait_time, target_locale)
|
|
100
|
+
sleep(wait_time)
|
|
101
|
+
request_summary(prompt, target_locale, attempt + 1)
|
|
149
102
|
rescue RateLimitError
|
|
150
103
|
# Le client a déjà relancé avec backoff : quelques tentatives de plus suffisent.
|
|
151
104
|
raise if attempt >= DEFAULT_RETRY_COUNT
|
|
152
105
|
|
|
153
106
|
log_rate_limit_hit("summary", target_locale)
|
|
154
107
|
sleep(DEFAULT_RETRY_DELAY)
|
|
155
|
-
|
|
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
|
|
108
|
+
request_summary(prompt, target_locale, attempt + 1)
|
|
187
109
|
end
|
|
188
110
|
|
|
189
111
|
def clean_document_content(content)
|
|
@@ -211,6 +133,8 @@ module MistralTranslator
|
|
|
211
133
|
|
|
212
134
|
def validate_summarize_inputs!(text, language, max_words)
|
|
213
135
|
raise ArgumentError, "Text cannot be nil or empty" if text.nil? || text.empty?
|
|
136
|
+
|
|
137
|
+
Security::BasicValidator.validate_text!(text)
|
|
214
138
|
raise ArgumentError, "Language cannot be nil" if language.nil?
|
|
215
139
|
raise ArgumentError, "Max words must be a positive integer" unless max_words.is_a?(Integer) && max_words.positive?
|
|
216
140
|
end
|