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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module MistralTranslator
6
+ # Proxy HTTP optionnel pour joindre l'API. Les identifiants du proxy
7
+ # ne figurent jamais dans les messages d'erreur.
8
+ module Proxy
9
+ # net-http-persistent ignore le schéma du proxy et lui parle toujours en clair :
10
+ # un proxy https:// enverrait ses identifiants sans chiffrement. Le flux vers
11
+ # l'API reste chiffré de bout en bout (CONNECT puis TLS).
12
+ SCHEME = "http"
13
+ API_PORT = 443
14
+
15
+ class << self
16
+ # nil : pas de proxy ; URI ou String : ce proxy ; :env : variables d'environnement
17
+ def validate!(setting)
18
+ return if setting.nil? || setting == :env
19
+
20
+ parse!(setting)
21
+ end
22
+
23
+ # URI du proxy à utiliser pour joindre host, ou nil
24
+ def resolve(setting, host:, env: ENV)
25
+ return nil if setting.nil?
26
+ return from_env(host, env) if setting == :env
27
+
28
+ parse!(setting)
29
+ end
30
+
31
+ private
32
+
33
+ # net-http-persistent ne lit que HTTP_PROXY : HTTPS_PROXY est lu ici, en priorité.
34
+ def from_env(host, env)
35
+ value = env_value(env, "https_proxy") || env_value(env, "http_proxy")
36
+ return nil if value.nil? || bypassed?(host, env_value(env, "no_proxy"))
37
+
38
+ parse!(value)
39
+ end
40
+
41
+ # Une variable vide, en minuscules, ne masque pas sa version en majuscules.
42
+ def env_value(env, name)
43
+ [env[name], env[name.upcase]].map { |value| value.to_s.strip }.find { |value| !value.empty? }
44
+ end
45
+
46
+ def bypassed?(host, no_proxy)
47
+ return false if no_proxy.nil?
48
+
49
+ no_proxy.split(",").any? { |entry| no_proxy_match?(host.downcase, entry.strip.downcase) }
50
+ end
51
+
52
+ # Comme curl : "*", un domaine et ses sous-domaines, avec ou sans point ou "*." initial.
53
+ # Une entrée avec un port ne vaut que pour le port de l'API.
54
+ def no_proxy_match?(host, entry)
55
+ return false if entry.empty?
56
+ return true if entry == "*"
57
+
58
+ domain, port = entry.split(":", 2)
59
+ return false if port && port.to_i != API_PORT
60
+
61
+ domain = domain.delete_prefix("*").delete_prefix(".")
62
+ host == domain || host.end_with?(".#{domain}")
63
+ end
64
+
65
+ def parse!(value)
66
+ uri = URI.parse(value.to_s)
67
+ return uri if uri.is_a?(URI::HTTP) && uri.scheme == SCHEME && uri.host && !uri.host.empty?
68
+
69
+ raise ConfigurationError, "Invalid proxy: expected an http:// URL with a host"
70
+ rescue URI::InvalidURIError
71
+ raise ConfigurationError, "Invalid proxy: malformed URL", cause: nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -4,47 +4,61 @@ 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
+
19
+ # Garde-fous contre les réponses démesurées
20
+ MAX_CONTENT_SIZE = 1_000_000
21
+ MAX_SCAN_LENGTH = 100_000
22
+ BRACE_DEPTH = { "{" => 1, "}" => -1 }.freeze
23
+
7
24
  class << self
8
25
  def parse_translation_response(raw_content)
9
26
  return nil if raw_content.nil? || raw_content.empty?
10
27
 
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}"
28
+ with_response_errors("translation", raw_content) do
29
+ data = parse_response_json(raw_content, "translation")
30
+ translated_text = target_text!(data, "Empty translation received from API", raw_content)
31
+
32
+ build_translation_result(data, translated_text)
26
33
  end
27
34
  end
28
35
 
29
36
  def parse_quality_check_response(raw_content)
30
37
  return { translation: nil, quality_check: {}, metadata: {} } if raw_content.nil? || raw_content.empty?
31
38
 
32
- json_content = extract_json_from_content(raw_content)
33
- raise InvalidResponseError, "Invalid JSON in quality check response" unless json_content
39
+ with_response_errors("quality check", raw_content) do
40
+ data = parse_response_json(raw_content, "quality check")
34
41
 
35
- data = JSON.parse(json_content)
42
+ {
43
+ translation: target_text!(data, "Empty translation received from API", raw_content),
44
+ quality_check: data["quality_check"] || value_at(data, %w[metadata quality_check]) || {},
45
+ metadata: metadata_of(data)
46
+ }
47
+ end
48
+ end
36
49
 
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}"
50
+ def parse_summary_response(raw_content)
51
+ return nil if raw_content.nil? || raw_content.empty?
52
+
53
+ with_response_errors("summary", raw_content) do
54
+ data = parse_response_json(raw_content, "summary")
55
+
56
+ {
57
+ original: extract_source_content(data),
58
+ summary: target_text!(data, "Empty summary received", raw_content),
59
+ metadata: metadata_of(data)
60
+ }
61
+ end
48
62
  end
49
63
 
50
64
  def parse_json_content(json_content)
@@ -62,234 +76,143 @@ module MistralTranslator
62
76
  end
63
77
  end
64
78
 
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
79
  def build_translation_result(translation_data, translated_text)
72
80
  {
73
81
  original: extract_source_content(translation_data),
74
82
  translated: translated_text,
75
- metadata: translation_data["metadata"] || {}
83
+ metadata: metadata_of(translation_data)
76
84
  }
77
85
  end
78
86
 
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?
85
- }
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
- end
93
-
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?
87
+ private
126
88
 
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
89
+ # Les erreurs Ruby (JSON::ParserError, NoMethodError, TypeError…) citent la réponse dans
90
+ # leur message : ni ce message ni l'erreur d'origine (cause) ne sont repris.
91
+ # La réponse reste accessible au code appelant via InvalidResponseError#raw_response.
92
+ def with_response_errors(label, raw_content)
93
+ yield
94
+ rescue EmptyTranslationError, InvalidResponseError
95
+ raise
96
+ rescue StandardError => e
97
+ raise InvalidResponseError.new("Error processing #{label} response (#{e.class})", raw_content), cause: nil
137
98
  end
138
- # rubocop:enable Metrics/PerceivedComplexity
139
99
 
140
- def parse_bulk_translation_response(raw_content)
141
- return [] if raw_content.nil? || raw_content.empty?
100
+ # Le mode JSON de l'API renvoie un objet seul, lu tel quel. Sinon, on lit le premier objet
101
+ # trouvé dans le texte, en réparant les continuations de ligne.
102
+ def parse_response_json(raw_content, label)
103
+ ensure_content_size!(raw_content)
104
+ data = parse_whole_or_embedded(raw_content, label)
105
+ raise InvalidResponseError.new("Unexpected JSON in #{label} response", raw_content) unless data.is_a?(Hash)
142
106
 
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)
151
-
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"
164
-
165
- raise InvalidResponseError, "Error processing bulk response: #{e.message}"
166
- end
107
+ data
167
108
  end
168
109
 
169
- private
170
-
171
- def extract_json_from_content(content)
172
- return nil if content.nil? || content.empty?
110
+ def ensure_content_size!(content)
111
+ return if content.length <= MAX_CONTENT_SIZE
173
112
 
174
- # Limiter la taille pour éviter les attaques DoS
175
- max_content_size = 1_000_000 # 1MB max
176
- if content.length > max_content_size
177
- raise InvalidResponseError, "Response content too large (#{content.length} bytes, max: #{max_content_size})"
178
- end
179
-
180
- # Essayer d'abord de parser directement le contenu comme JSON
181
- begin
182
- JSON.parse(content)
183
- content
184
- rescue JSON::ParserError
185
- # Si ça échoue, chercher le JSON dans la réponse (peut être entouré de texte)
186
- # Utiliser une approche plus robuste pour les JSON complexes
187
- find_json_in_text(content)
188
- end
113
+ raise InvalidResponseError, "Response content too large (#{content.length} bytes, max: #{MAX_CONTENT_SIZE})"
189
114
  end
190
115
 
191
- def find_json_in_text(text)
192
- start_pos = find_json_start(text)
193
- return nil unless start_pos
116
+ def parse_whole_or_embedded(raw_content, label)
117
+ JSON.parse(raw_content)
118
+ rescue JSON::ParserError
119
+ json_content = find_json_in_text(raw_content)
120
+ raise InvalidResponseError.new("No JSON in #{label} response", raw_content), cause: nil unless json_content
194
121
 
195
- parse_json_until_end(text, start_pos)
122
+ parse_embedded_json(json_content, raw_content, label)
196
123
  end
197
124
 
198
- def find_json_start(text)
199
- text.index("{")
125
+ def parse_embedded_json(json_content, raw_content, label)
126
+ parse_json_content(json_content)
127
+ rescue JSON::ParserError
128
+ details = "raw_len=#{raw_content.length} json_len=#{json_content.length}"
129
+ Logger.debug_if_verbose("JSON parse failed: #{details}", sensitive: false)
130
+ raise InvalidResponseError.new("Invalid JSON in #{label} response (#{details})", raw_content), cause: nil
200
131
  end
201
132
 
202
- def parse_json_until_end(text, start_pos)
203
- parser_state = JsonParserState.new
204
- max_iterations = 100_000
133
+ # Le premier objet du texte, de sa première accolade à celle qui la referme.
134
+ def find_json_in_text(text)
135
+ start = text.index("{")
136
+ finish = closing_brace_index(text, start) if start
137
+ text[start..finish] if finish
138
+ end
205
139
 
206
- (start_pos...text.length).each do |i|
207
- parser_state.increment_iterations
208
- if parser_state.iterations > max_iterations
209
- raise InvalidResponseError,
210
- "JSON parsing exceeded maximum iterations"
140
+ # Les accolades des chaînes ne comptent pas ; le parcours est plafonné.
141
+ def closing_brace_index(text, start)
142
+ depth = 0
143
+ in_string = false
144
+ escaped = false
145
+
146
+ (start...text.length).each do |index|
147
+ ensure_scan_length!(index - start)
148
+ char = text[index]
149
+
150
+ if escaped
151
+ escaped = false
152
+ elsif char == "\\"
153
+ escaped = true
154
+ elsif char == '"'
155
+ in_string = !in_string
156
+ elsif !in_string
157
+ depth += BRACE_DEPTH.fetch(char, 0)
158
+ return index if depth.zero?
211
159
  end
212
-
213
- char = text[i]
214
- parser_state.process_character(char)
215
-
216
- return text[start_pos..i] if parser_state.found_complete_json?
217
160
  end
218
161
 
219
162
  nil
220
163
  end
221
164
 
222
- # Helper class pour gérer l'état du parsing JSON
223
- class JsonParserState
224
- attr_reader :iterations
165
+ def ensure_scan_length!(scanned)
166
+ return if scanned < MAX_SCAN_LENGTH
225
167
 
226
- def initialize
227
- @brace_count = 0
228
- @in_string = false
229
- @escape_next = false
230
- @iterations = 0
231
- end
232
-
233
- def increment_iterations
234
- @iterations += 1
235
- end
168
+ raise InvalidResponseError, "JSON parsing exceeded maximum iterations", cause: nil
169
+ end
236
170
 
237
- def process_character(char)
238
- return handle_escape_character if @escape_next
239
- return handle_backslash_character if char == "\\"
240
- return handle_quote_character(char) if char == '"' && !@escape_next
241
- return if @in_string
171
+ # Le texte traduit ou résumé : toujours une String non vide, jamais un objet ou un tableau.
172
+ def target_text!(data, empty_message, raw_content)
173
+ target = find_target(data)
174
+ nested = nested_response(target)
175
+ target = find_target(nested) || "" if nested && target_candidates(nested).any?
242
176
 
243
- handle_brace_character(char)
177
+ unless target.nil? || target.is_a?(String)
178
+ raise InvalidResponseError.new("Unexpected target in response", raw_content)
244
179
  end
180
+ raise EmptyTranslationError, empty_message if target.to_s.strip.empty?
245
181
 
246
- def handle_escape_character
247
- @escape_next = false
248
- end
182
+ target
183
+ end
249
184
 
250
- def handle_backslash_character
251
- @escape_next = true
252
- end
185
+ def find_target(data)
186
+ target_candidates(data).find { |item| !(item.respond_to?(:empty?) && item.empty?) }
187
+ end
253
188
 
254
- def handle_quote_character(_char)
255
- @in_string = !@in_string
256
- end
189
+ def target_candidates(data)
190
+ TARGET_PATHS.filter_map { |path| value_at(data, path) }
191
+ end
257
192
 
258
- def handle_brace_character(char)
259
- if char == "{"
260
- @brace_count += 1
261
- elsif char == "}"
262
- @brace_count -= 1
263
- end
264
- end
193
+ # Comme Hash#dig, mais un nœud intermédiaire qui n'est pas un objet donne nil au lieu de TypeError.
194
+ def value_at(data, path)
195
+ path.reduce(data) { |node, key| node.is_a?(Hash) ? node[key] : nil }
196
+ end
265
197
 
266
- def found_complete_json?
267
- @brace_count.zero?
268
- end
198
+ def metadata_of(data)
199
+ data["metadata"].is_a?(Hash) ? data["metadata"] : {}
269
200
  end
270
201
 
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? }
202
+ # Le modèle renvoie parfois l'objet de réponse attendu, tel quel ou sérialisé, dans "target" :
203
+ # on y cherche la cible un niveau plus bas. Un JSON sans clé connue reste une traduction.
204
+ def nested_response(target)
205
+ return target if target.is_a?(Hash)
206
+ return nil unless target.is_a?(String) && target.lstrip.start_with?("{")
207
+
208
+ parsed = JSON.parse(target)
209
+ parsed if parsed.is_a?(Hash)
210
+ rescue JSON::ParserError
211
+ nil
282
212
  end
283
213
 
284
214
  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? }
215
+ SOURCE_PATHS.map { |path| value_at(data, path) }.find { |item| item.is_a?(String) && !item.empty? }
293
216
  end
294
217
  end
295
218
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "logger"
4
+
5
+ module MistralTranslator
6
+ # Relances communes aux traductions et aux résumés.
7
+ # Le client relance déjà les 429 avec backoff : ici, quelques tentatives de plus suffisent.
8
+ module Retries
9
+ RETRY_COUNT = 3
10
+ RETRY_DELAY = 2
11
+
12
+ private
13
+
14
+ # Exécute le bloc, qui reçoit le numéro de tentative (0 pour la première).
15
+ # Une réponse inexploitable est relancée avec un backoff exponentiel, un quota dépassé
16
+ # après un délai fixe ; au-delà de RETRY_COUNT relances, l'erreur remonte.
17
+ # operation et subject nomment la requête dans les journaux, jamais son contenu.
18
+ def with_retries(operation:, subject:)
19
+ attempt = 0
20
+ begin
21
+ yield attempt
22
+ rescue EmptyTranslationError, InvalidResponseError => e
23
+ raise if attempt >= RETRY_COUNT
24
+
25
+ wait_time = RETRY_DELAY * (2**attempt)
26
+ attempt += 1
27
+ log_retry(operation, subject, e, attempt, wait_time)
28
+ sleep(wait_time)
29
+ retry
30
+ rescue RateLimitError
31
+ raise if attempt >= RETRY_COUNT
32
+
33
+ attempt += 1
34
+ log_rate_limit_hit(operation, subject)
35
+ sleep(RETRY_DELAY)
36
+ retry
37
+ end
38
+ end
39
+
40
+ def log_retry(operation, subject, error, attempt, wait_time)
41
+ message = "#{operation} retry #{attempt}/#{RETRY_COUNT} for #{subject} in #{wait_time}s: #{error.message}"
42
+ Logger.warn_once(message, key: "#{operation}_retry_#{subject}_#{error.class.name}", sensitive: false, ttl: 120)
43
+ end
44
+
45
+ def log_rate_limit_hit(operation, subject)
46
+ Logger.warn_once("Rate limit hit for #{operation} #{subject}, retrying",
47
+ key: "#{operation}_rate_limit_#{subject}", sensitive: false, ttl: 300)
48
+ end
49
+ end
50
+ end