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.
@@ -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
- begin
12
- json_content = extract_json_from_content(raw_content)
13
- return nil unless json_content
14
-
15
- translation_data = parse_json_content(json_content)
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
- json_content = extract_json_from_content(raw_content)
33
- raise InvalidResponseError, "Invalid JSON in quality check response" unless json_content
34
+ with_response_errors("quality check", raw_content) do
35
+ data = parse_response_json(raw_content, "quality check")
34
36
 
35
- data = JSON.parse(json_content)
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
- translation = extract_target_content(data)
38
- quality = data["quality_check"] || data.dig("metadata", "quality_check") || {}
39
- {
40
- translation: translation,
41
- quality_check: quality,
42
- metadata: data["metadata"] || {}
43
- }
44
- rescue JSON::ParserError
45
- raise InvalidResponseError, "Invalid JSON in quality check response"
46
- rescue StandardError => e
47
- raise InvalidResponseError, "Error processing quality check response: #{e.message}"
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["metadata"] || {}
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
- # rubocop:disable Metrics/PerceivedComplexity
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
- original: nil,
129
- summary: text,
130
- metadata: { "operation" => "summarization", "fallback" => true }
131
- }
132
- rescue EmptyTranslationError
133
- raise # Re-raise EmptyTranslationError
134
- rescue StandardError => e
135
- raise InvalidResponseError, "Error processing summary response: #{e.message}"
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 parse_bulk_translation_response(raw_content)
141
- return [] if raw_content.nil? || raw_content.empty?
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
- translations.map do |translation|
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
- raise InvalidResponseError, "Error processing bulk response: #{e.message}"
166
- end
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
- def extract_target_content(data)
272
- # Essayer différents chemins possibles pour le contenu traduit
273
- [
274
- data.dig("content", "target"),
275
- data.dig("translation", "target"),
276
- data["target"],
277
- data.dig("content", "translated"),
278
- data["translated"],
279
- data.dig("content", "summary"),
280
- data["summary"]
281
- ].find { |item| item && !item.to_s.empty? }
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
- # Essayer différents chemins possibles pour le contenu source
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
- result = summarize_with_retry(cleaned_text, target_locale, max_words)
35
- log_debug("Summary completed successfully")
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
- log_debug("Same language detected, using simple summarize")
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
- result[:summary]
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
- results[target_locale] = summarize_with_retry(cleaned_text, target_locale, max_words)
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
- short: summarize_with_retry(cleaned_text, target_locale, short),
113
- medium: summarize_with_retry(cleaned_text, target_locale, medium),
114
- long: summarize_with_retry(cleaned_text, target_locale, long)
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
- # rubocop:disable Metrics/PerceivedComplexity
121
- def summarize_with_retry(text, target_locale, max_words, attempt = 0)
122
- log_debug("Summarize attempt #{attempt + 1} for #{target_locale}")
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
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 < 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
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
- 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
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