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
|
@@ -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
|
-
|
|
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}"
|
|
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
|
-
|
|
33
|
-
|
|
39
|
+
with_response_errors("quality check", raw_content) do
|
|
40
|
+
data = parse_response_json(raw_content, "quality check")
|
|
34
41
|
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
83
|
+
metadata: metadata_of(translation_data)
|
|
76
84
|
}
|
|
77
85
|
end
|
|
78
86
|
|
|
79
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
122
|
+
parse_embedded_json(json_content, raw_content, label)
|
|
196
123
|
end
|
|
197
124
|
|
|
198
|
-
def
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
attr_reader :iterations
|
|
165
|
+
def ensure_scan_length!(scanned)
|
|
166
|
+
return if scanned < MAX_SCAN_LENGTH
|
|
225
167
|
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
end
|
|
182
|
+
target
|
|
183
|
+
end
|
|
249
184
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
185
|
+
def find_target(data)
|
|
186
|
+
target_candidates(data).find { |item| !(item.respond_to?(:empty?) && item.empty?) }
|
|
187
|
+
end
|
|
253
188
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
189
|
+
def target_candidates(data)
|
|
190
|
+
TARGET_PATHS.filter_map { |path| value_at(data, path) }
|
|
191
|
+
end
|
|
257
192
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
end
|
|
198
|
+
def metadata_of(data)
|
|
199
|
+
data["metadata"].is_a?(Hash) ? data["metadata"] : {}
|
|
269
200
|
end
|
|
270
201
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|