mistral_translator 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +493 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +205 -0
- data/Rakefile +12 -0
- data/examples/basic_usage.rb +135 -0
- data/lib/mistral_translator/client.rb +147 -0
- data/lib/mistral_translator/configuration.rb +39 -0
- data/lib/mistral_translator/errors.rb +53 -0
- data/lib/mistral_translator/locale_helper.rb +99 -0
- data/lib/mistral_translator/logger.rb +55 -0
- data/lib/mistral_translator/prompt_builder.rb +219 -0
- data/lib/mistral_translator/response_parser.rb +125 -0
- data/lib/mistral_translator/summarizer.rb +226 -0
- data/lib/mistral_translator/translator.rb +177 -0
- data/lib/mistral_translator/version.rb +20 -0
- data/lib/mistral_translator.rb +138 -0
- data/sig/mistral_translator.rbs +4 -0
- metadata +150 -0
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MistralTranslator
|
4
|
+
module Logger
|
5
|
+
class << self
|
6
|
+
def info(message, sensitive: false)
|
7
|
+
log(:info, message, sensitive)
|
8
|
+
end
|
9
|
+
|
10
|
+
def warn(message, sensitive: false)
|
11
|
+
log(:warn, message, sensitive)
|
12
|
+
end
|
13
|
+
|
14
|
+
def debug(message, sensitive: false)
|
15
|
+
log(:debug, message, sensitive)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Log seulement si pas déjà loggé récemment (évite la spam)
|
19
|
+
def warn_once(message, key: nil, sensitive: false, ttl: 300)
|
20
|
+
@warn_cache ||= {}
|
21
|
+
cache_key = key || message
|
22
|
+
|
23
|
+
return unless should_log_warning?(cache_key, ttl)
|
24
|
+
|
25
|
+
@warn_cache[cache_key] = Time.now
|
26
|
+
log(:warn, message, sensitive)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Log de debug seulement si vraiment nécessaire
|
30
|
+
def debug_if_verbose(message, sensitive: false)
|
31
|
+
return unless ENV["MISTRAL_TRANSLATOR_VERBOSE"] == "true"
|
32
|
+
|
33
|
+
log(:debug, message, sensitive)
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def log(level, message, sensitive)
|
39
|
+
# En mode Rails, utiliser le logger Rails
|
40
|
+
if defined?(Rails) && Rails.respond_to?(:logger)
|
41
|
+
Rails.logger.public_send(level, "[MistralTranslator] #{message}")
|
42
|
+
# Sinon, utiliser puts seulement si pas sensible et debug activé
|
43
|
+
elsif !sensitive && ENV["MISTRAL_TRANSLATOR_DEBUG"] == "true"
|
44
|
+
puts "[MistralTranslator] #{message}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def should_log_warning?(key, ttl)
|
49
|
+
return true unless @warn_cache[key]
|
50
|
+
|
51
|
+
Time.now - @warn_cache[key] > ttl
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "logger"
|
4
|
+
|
5
|
+
module MistralTranslator
|
6
|
+
module PromptBuilder
|
7
|
+
class << self
|
8
|
+
def translation_prompt(text, source_language, target_language)
|
9
|
+
source_name = LocaleHelper.locale_to_language(source_language)
|
10
|
+
target_name = LocaleHelper.locale_to_language(target_language)
|
11
|
+
|
12
|
+
<<~PROMPT
|
13
|
+
Tu es un traducteur professionnel. Traduis le texte suivant de #{source_name} vers #{target_name}.
|
14
|
+
|
15
|
+
RÈGLES :
|
16
|
+
- Traduis fidèlement sans ajouter d'informations
|
17
|
+
- Conserve le style, ton et format original
|
18
|
+
- Réponds UNIQUEMENT en JSON valide
|
19
|
+
|
20
|
+
FORMAT OBLIGATOIRE :
|
21
|
+
{
|
22
|
+
"content": {
|
23
|
+
"source": "texte original",
|
24
|
+
"target": "texte traduit en #{target_name}"
|
25
|
+
},
|
26
|
+
"metadata": {
|
27
|
+
"source_language": "#{source_language}",
|
28
|
+
"target_language": "#{target_language}",
|
29
|
+
"operation": "translation"
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
TEXTE À TRADUIRE :
|
34
|
+
#{text}
|
35
|
+
PROMPT
|
36
|
+
end
|
37
|
+
|
38
|
+
def bulk_translation_prompt(texts, source_language, target_language)
|
39
|
+
source_name = LocaleHelper.locale_to_language(source_language)
|
40
|
+
target_name = LocaleHelper.locale_to_language(target_language)
|
41
|
+
|
42
|
+
<<~PROMPT
|
43
|
+
Tu es un traducteur professionnel. Traduis les textes suivants de #{source_name} vers #{target_name}.
|
44
|
+
|
45
|
+
RÈGLES :
|
46
|
+
- Traduis fidèlement chaque texte sans ajouter d'informations
|
47
|
+
- Conserve le style, ton et format originaux
|
48
|
+
- Réponds UNIQUEMENT en JSON valide
|
49
|
+
|
50
|
+
FORMAT OBLIGATOIRE :
|
51
|
+
{
|
52
|
+
"translations": [
|
53
|
+
{
|
54
|
+
"index": 1,
|
55
|
+
"source": "texte original 1",
|
56
|
+
"target": "texte traduit 1"
|
57
|
+
},
|
58
|
+
{
|
59
|
+
"index": 2,
|
60
|
+
"source": "texte original 2",
|
61
|
+
"target": "texte traduit 2"
|
62
|
+
}
|
63
|
+
],
|
64
|
+
"metadata": {
|
65
|
+
"source_language": "#{source_language}",
|
66
|
+
"target_language": "#{target_language}",
|
67
|
+
"count": #{texts.length},
|
68
|
+
"operation": "bulk_translation"
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
TEXTES À TRADUIRE :
|
73
|
+
#{texts.map.with_index { |text, i| "#{i + 1}. #{text}" }.join("\n")}
|
74
|
+
PROMPT
|
75
|
+
end
|
76
|
+
|
77
|
+
def summary_prompt(text, max_words, target_language = "fr")
|
78
|
+
target_name = LocaleHelper.locale_to_language(target_language)
|
79
|
+
|
80
|
+
<<~PROMPT
|
81
|
+
Tu es un rédacteur professionnel. Résume le texte suivant en #{target_name}.
|
82
|
+
|
83
|
+
RÈGLES :
|
84
|
+
- Résume fidèlement sans ajouter d'informations
|
85
|
+
- Maximum #{max_words} mots
|
86
|
+
- Conserve les informations essentielles
|
87
|
+
- Réponds UNIQUEMENT en JSON valide
|
88
|
+
|
89
|
+
FORMAT OBLIGATOIRE :
|
90
|
+
{
|
91
|
+
"content": {
|
92
|
+
"source": "texte original",
|
93
|
+
"target": "résumé en #{target_name}"
|
94
|
+
},
|
95
|
+
"metadata": {
|
96
|
+
"source_language": "original",
|
97
|
+
"target_language": "#{target_language}",
|
98
|
+
"word_count": #{max_words},
|
99
|
+
"operation": "summarization"
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
103
|
+
TEXTE À RÉSUMER :
|
104
|
+
#{text}
|
105
|
+
PROMPT
|
106
|
+
end
|
107
|
+
|
108
|
+
def summary_and_translation_prompt(text, source_language, target_language, max_words)
|
109
|
+
source_name = LocaleHelper.locale_to_language(source_language)
|
110
|
+
target_name = LocaleHelper.locale_to_language(target_language)
|
111
|
+
|
112
|
+
<<~PROMPT
|
113
|
+
Tu es un rédacteur professionnel. Résume ET traduis le texte suivant de #{source_name} vers #{target_name}.
|
114
|
+
|
115
|
+
RÈGLES :
|
116
|
+
- Résume fidèlement sans ajouter d'informations
|
117
|
+
- Traduis le résumé en #{target_name}
|
118
|
+
- Maximum #{max_words} mots
|
119
|
+
- Réponds UNIQUEMENT en JSON valide
|
120
|
+
|
121
|
+
FORMAT OBLIGATOIRE :
|
122
|
+
{
|
123
|
+
"content": {
|
124
|
+
"source": "texte original",
|
125
|
+
"target": "résumé traduit en #{target_name}"
|
126
|
+
},
|
127
|
+
"metadata": {
|
128
|
+
"source_language": "#{source_language}",
|
129
|
+
"target_language": "#{target_language}",
|
130
|
+
"word_count": #{max_words},
|
131
|
+
"operation": "summarization_and_translation"
|
132
|
+
}
|
133
|
+
}
|
134
|
+
|
135
|
+
TEXTE À RÉSUMER ET TRADUIRE :
|
136
|
+
#{text}
|
137
|
+
PROMPT
|
138
|
+
end
|
139
|
+
|
140
|
+
def tiered_summary_prompt(text, target_language, short, medium, long)
|
141
|
+
target_name = LocaleHelper.locale_to_language(target_language)
|
142
|
+
|
143
|
+
<<~PROMPT
|
144
|
+
Tu es un rédacteur professionnel. Crée trois résumés du texte suivant en #{target_name}.
|
145
|
+
|
146
|
+
RÈGLES :
|
147
|
+
- Résume fidèlement sans ajouter d'informations
|
148
|
+
- Respecte strictement les longueurs demandées
|
149
|
+
- Réponds UNIQUEMENT en JSON valide
|
150
|
+
|
151
|
+
FORMAT OBLIGATOIRE :
|
152
|
+
{
|
153
|
+
"content": {
|
154
|
+
"source": "texte original",
|
155
|
+
"target": "résumés en #{target_name}"
|
156
|
+
},
|
157
|
+
"metadata": {
|
158
|
+
"source_language": "original",
|
159
|
+
"target_language": "#{target_language}",
|
160
|
+
"summaries": {
|
161
|
+
"short": #{short},
|
162
|
+
"medium": #{medium},
|
163
|
+
"long": #{long}
|
164
|
+
},
|
165
|
+
"operation": "tiered_summarization"
|
166
|
+
}
|
167
|
+
}
|
168
|
+
|
169
|
+
TEXTE À RÉSUMER :
|
170
|
+
#{text}
|
171
|
+
PROMPT
|
172
|
+
end
|
173
|
+
|
174
|
+
def language_detection_prompt(text)
|
175
|
+
<<~PROMPT
|
176
|
+
Tu es un expert en linguistique. Détecte la langue du texte suivant.
|
177
|
+
|
178
|
+
RÈGLES :
|
179
|
+
- Identifie la langue principale
|
180
|
+
- Utilise le code ISO 639-1 (ex: 'fr', 'en', 'es')
|
181
|
+
- Réponds UNIQUEMENT en JSON valide
|
182
|
+
|
183
|
+
FORMAT OBLIGATOIRE :
|
184
|
+
{
|
185
|
+
"content": {
|
186
|
+
"source": "texte analysé",
|
187
|
+
"target": "langue détectée"
|
188
|
+
},
|
189
|
+
"metadata": {
|
190
|
+
"detected_language": "code_iso",
|
191
|
+
"operation": "language_detection"
|
192
|
+
}
|
193
|
+
}
|
194
|
+
|
195
|
+
TEXTE À ANALYSER :
|
196
|
+
#{text}
|
197
|
+
PROMPT
|
198
|
+
end
|
199
|
+
|
200
|
+
private
|
201
|
+
|
202
|
+
def log_prompt_generation(prompt_type, source_locale, target_locale)
|
203
|
+
message = "Generated #{prompt_type} prompt for #{source_locale} -> #{target_locale}"
|
204
|
+
Logger.debug_if_verbose(message, sensitive: false)
|
205
|
+
end
|
206
|
+
|
207
|
+
def log_prompt_debug(_prompt)
|
208
|
+
return unless ENV["MISTRAL_TRANSLATOR_DEBUG"]
|
209
|
+
|
210
|
+
if defined?(Rails) && Rails.respond_to?(:logger)
|
211
|
+
Rails.logger.info message
|
212
|
+
elsif ENV["MISTRAL_TRANSLATOR_DEBUG"]
|
213
|
+
# Log de debug seulement si mode verbose activé
|
214
|
+
Logger.debug_if_verbose(message, sensitive: false)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MistralTranslator
|
4
|
+
class ResponseParser
|
5
|
+
class << self
|
6
|
+
def parse_translation_response(raw_content)
|
7
|
+
return nil if raw_content.nil? || raw_content.empty?
|
8
|
+
|
9
|
+
begin
|
10
|
+
# Extraire le JSON de la réponse (peut contenir du texte avant/après)
|
11
|
+
json_content = extract_json_from_content(raw_content)
|
12
|
+
return nil unless json_content
|
13
|
+
|
14
|
+
# Parser le JSON
|
15
|
+
translation_data = JSON.parse(json_content)
|
16
|
+
|
17
|
+
# Extraire le contenu traduit selon différents formats possibles
|
18
|
+
translated_text = extract_target_content(translation_data)
|
19
|
+
|
20
|
+
# Vérifier si la traduction est vide et lever l'erreur appropriée
|
21
|
+
if translated_text.nil? || translated_text.empty?
|
22
|
+
raise EmptyTranslationError, "Empty translation received from API"
|
23
|
+
end
|
24
|
+
|
25
|
+
{
|
26
|
+
original: extract_source_content(translation_data),
|
27
|
+
translated: translated_text,
|
28
|
+
metadata: translation_data["metadata"] || {}
|
29
|
+
}
|
30
|
+
rescue JSON::ParserError
|
31
|
+
raise InvalidResponseError, "Invalid JSON in response: #{raw_content}"
|
32
|
+
rescue EmptyTranslationError
|
33
|
+
raise # Re-raise EmptyTranslationError
|
34
|
+
rescue StandardError => e
|
35
|
+
raise InvalidResponseError, "Error processing response: #{e.message}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def parse_summary_response(raw_content)
|
40
|
+
return nil if raw_content.nil? || raw_content.empty?
|
41
|
+
|
42
|
+
begin
|
43
|
+
json_content = extract_json_from_content(raw_content)
|
44
|
+
return nil unless json_content
|
45
|
+
|
46
|
+
summary_data = JSON.parse(json_content)
|
47
|
+
summary_text = extract_target_content(summary_data)
|
48
|
+
|
49
|
+
raise EmptyTranslationError, "Empty summary received" if summary_text.nil? || summary_text.empty?
|
50
|
+
|
51
|
+
{
|
52
|
+
original: extract_source_content(summary_data),
|
53
|
+
summary: summary_text,
|
54
|
+
metadata: summary_data["metadata"] || {}
|
55
|
+
}
|
56
|
+
rescue JSON::ParserError
|
57
|
+
raise InvalidResponseError, "Invalid JSON in summary response: #{raw_content}"
|
58
|
+
rescue EmptyTranslationError
|
59
|
+
raise # Re-raise EmptyTranslationError
|
60
|
+
rescue StandardError => e
|
61
|
+
raise InvalidResponseError, "Error processing summary response: #{e.message}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def parse_bulk_translation_response(raw_content)
|
66
|
+
return [] if raw_content.nil? || raw_content.empty?
|
67
|
+
|
68
|
+
begin
|
69
|
+
json_content = extract_json_from_content(raw_content)
|
70
|
+
raise InvalidResponseError, "Invalid JSON in bulk response: #{raw_content}" unless json_content
|
71
|
+
|
72
|
+
bulk_data = JSON.parse(json_content)
|
73
|
+
translations = bulk_data["translations"]
|
74
|
+
|
75
|
+
raise InvalidResponseError, "No translations array in response" unless translations.is_a?(Array)
|
76
|
+
|
77
|
+
translations.map do |translation|
|
78
|
+
{
|
79
|
+
index: translation["index"],
|
80
|
+
original: translation["source"],
|
81
|
+
translated: translation["target"]
|
82
|
+
}
|
83
|
+
end
|
84
|
+
rescue JSON::ParserError
|
85
|
+
raise InvalidResponseError, "Invalid JSON in bulk response: #{raw_content}"
|
86
|
+
rescue StandardError => e
|
87
|
+
# Ne pas wrapper l'erreur "No translations array in response"
|
88
|
+
raise e if e.message == "No translations array in response"
|
89
|
+
|
90
|
+
raise InvalidResponseError, "Error processing bulk response: #{e.message}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def extract_json_from_content(content)
|
97
|
+
# Chercher le JSON dans la réponse (peut être entouré de texte)
|
98
|
+
json_match = content.match(/\{.*\}/m)
|
99
|
+
json_match&.[](0)
|
100
|
+
end
|
101
|
+
|
102
|
+
def extract_target_content(data)
|
103
|
+
# Essayer différents chemins possibles pour le contenu traduit
|
104
|
+
[
|
105
|
+
data.dig("content", "target"),
|
106
|
+
data.dig("translation", "target"),
|
107
|
+
data["target"],
|
108
|
+
data.dig("content", "translated"),
|
109
|
+
data["translated"]
|
110
|
+
].find { |item| item && !item.to_s.empty? }
|
111
|
+
end
|
112
|
+
|
113
|
+
def extract_source_content(data)
|
114
|
+
# Essayer différents chemins possibles pour le contenu source
|
115
|
+
[
|
116
|
+
data.dig("content", "source"),
|
117
|
+
data.dig("translation", "source"),
|
118
|
+
data["source"],
|
119
|
+
data.dig("content", "original"),
|
120
|
+
data["original"]
|
121
|
+
].find { |item| item && !item.to_s.empty? }
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "logger"
|
4
|
+
|
5
|
+
module MistralTranslator
|
6
|
+
class Summarizer
|
7
|
+
DEFAULT_MAX_WORDS = 250
|
8
|
+
DEFAULT_RETRY_COUNT = 3
|
9
|
+
DEFAULT_RETRY_DELAY = 2
|
10
|
+
|
11
|
+
def initialize(client: nil)
|
12
|
+
@client = client || Client.new
|
13
|
+
log_debug("Summarizer initialized")
|
14
|
+
end
|
15
|
+
|
16
|
+
# Résumé simple dans une langue donnée
|
17
|
+
def summarize(text, language: "fr", max_words: DEFAULT_MAX_WORDS)
|
18
|
+
log_debug("Starting summarize - language: #{language}, max_words: #{max_words}")
|
19
|
+
validate_summarize_inputs!(text, language, max_words)
|
20
|
+
|
21
|
+
target_locale = LocaleHelper.validate_locale!(language)
|
22
|
+
log_debug("Target locale validated: #{target_locale}")
|
23
|
+
|
24
|
+
cleaned_text = clean_document_content(text)
|
25
|
+
log_debug("Text cleaned, length: #{cleaned_text&.length}")
|
26
|
+
|
27
|
+
result = summarize_with_retry(cleaned_text, target_locale, max_words)
|
28
|
+
log_debug("Summary completed successfully")
|
29
|
+
result
|
30
|
+
end
|
31
|
+
|
32
|
+
# Résumé avec traduction simultanée
|
33
|
+
def summarize_and_translate(text, from:, to:, max_words: DEFAULT_MAX_WORDS)
|
34
|
+
log_debug("Starting summarize_and_translate - from: #{from}, to: #{to}")
|
35
|
+
validate_summarize_translate_inputs!(text, from, to, max_words)
|
36
|
+
|
37
|
+
source_locale = LocaleHelper.validate_locale!(from)
|
38
|
+
target_locale = LocaleHelper.validate_locale!(to)
|
39
|
+
cleaned_text = clean_document_content(text)
|
40
|
+
|
41
|
+
# Si même langue, juste résumer
|
42
|
+
if source_locale == target_locale
|
43
|
+
log_debug("Same language detected, using simple summarize")
|
44
|
+
return summarize(cleaned_text, language: target_locale, max_words: max_words)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Sinon, créer un prompt qui fait les deux à la fois
|
48
|
+
log_debug("Different languages, using combined summarize+translate")
|
49
|
+
prompt = build_summary_translation_prompt(cleaned_text, source_locale, target_locale, max_words)
|
50
|
+
raw_response = @client.complete(prompt)
|
51
|
+
|
52
|
+
result = ResponseParser.parse_summary_response(raw_response)
|
53
|
+
result[:summary]
|
54
|
+
end
|
55
|
+
|
56
|
+
# Résumé en plusieurs langues
|
57
|
+
def summarize_to_multiple(text, languages:, max_words: DEFAULT_MAX_WORDS)
|
58
|
+
log_debug("Starting summarize_to_multiple - languages: #{languages}")
|
59
|
+
validate_multiple_summarize_inputs!(text, languages, max_words)
|
60
|
+
|
61
|
+
target_locales = Array(languages).map { |locale| LocaleHelper.validate_locale!(locale) }
|
62
|
+
cleaned_text = clean_document_content(text)
|
63
|
+
results = {}
|
64
|
+
|
65
|
+
target_locales.each_with_index do |target_locale, index|
|
66
|
+
log_debug("Processing language #{index + 1}/#{target_locales.length}: #{target_locale}")
|
67
|
+
|
68
|
+
# Ajouter un délai seulement entre les requêtes (pas avant la première)
|
69
|
+
if index.positive?
|
70
|
+
log_debug("Adding delay between requests: #{DEFAULT_RETRY_DELAY}s")
|
71
|
+
sleep(DEFAULT_RETRY_DELAY)
|
72
|
+
end
|
73
|
+
|
74
|
+
results[target_locale] = summarize_with_retry(cleaned_text, target_locale, max_words)
|
75
|
+
end
|
76
|
+
|
77
|
+
log_debug("Multiple summarization completed")
|
78
|
+
results
|
79
|
+
end
|
80
|
+
|
81
|
+
# Résumé par niveaux (court, moyen, long)
|
82
|
+
def summarize_tiered(text, language: "fr", short: 50, medium: 150, long: 300)
|
83
|
+
log_debug("Starting summarize_tiered - short: #{short}, medium: #{medium}, long: #{long}")
|
84
|
+
validate_tiered_inputs!(text, language, short, medium, long)
|
85
|
+
|
86
|
+
target_locale = LocaleHelper.validate_locale!(language)
|
87
|
+
cleaned_text = clean_document_content(text)
|
88
|
+
|
89
|
+
{
|
90
|
+
short: summarize_with_retry(cleaned_text, target_locale, short),
|
91
|
+
medium: summarize_with_retry(cleaned_text, target_locale, medium),
|
92
|
+
long: summarize_with_retry(cleaned_text, target_locale, long)
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def summarize_with_retry(text, target_locale, max_words, attempt = 0)
|
99
|
+
log_debug("Summarize attempt #{attempt + 1} for #{target_locale}")
|
100
|
+
|
101
|
+
prompt = PromptBuilder.summary_prompt(text, max_words, target_locale)
|
102
|
+
raw_response = @client.complete(prompt)
|
103
|
+
|
104
|
+
result = ResponseParser.parse_summary_response(raw_response)
|
105
|
+
if result.nil? || result[:summary].nil? || result[:summary].empty?
|
106
|
+
raise EmptyTranslationError, "Empty summary received"
|
107
|
+
end
|
108
|
+
|
109
|
+
log_debug("Summary successful for #{target_locale}")
|
110
|
+
result[:summary]
|
111
|
+
rescue EmptyTranslationError, InvalidResponseError => e
|
112
|
+
if attempt < DEFAULT_RETRY_COUNT
|
113
|
+
wait_time = DEFAULT_RETRY_DELAY * (2**attempt)
|
114
|
+
log_retry(e, attempt + 1, wait_time, target_locale)
|
115
|
+
sleep(wait_time)
|
116
|
+
summarize_with_retry(text, target_locale, max_words, attempt + 1)
|
117
|
+
else
|
118
|
+
log_debug("Max retries reached for #{target_locale}, giving up")
|
119
|
+
raise e
|
120
|
+
end
|
121
|
+
rescue RateLimitError => e
|
122
|
+
log_rate_limit_hit("summary", target_locale)
|
123
|
+
sleep(DEFAULT_RETRY_DELAY)
|
124
|
+
retry
|
125
|
+
end
|
126
|
+
|
127
|
+
def build_summary_translation_prompt(text, source_locale, target_locale, max_words)
|
128
|
+
source_name = LocaleHelper.locale_to_language(source_locale)
|
129
|
+
target_name = LocaleHelper.locale_to_language(target_locale)
|
130
|
+
|
131
|
+
<<~PROMPT
|
132
|
+
Tu es un assistant spécialisé dans la création de résumés et traductions simultanées.#{" "}
|
133
|
+
Résume ET traduis le texte suivant en respectant ces règles strictes :
|
134
|
+
1. Langue source : #{source_name} (#{source_locale})
|
135
|
+
2. Langue cible : #{target_name} (#{target_locale})
|
136
|
+
3. Longueur maximale : #{max_words} mots
|
137
|
+
4. Créer un résumé du texte ET le traduire vers la langue cible
|
138
|
+
5. Format de réponse obligatoire en JSON :
|
139
|
+
{
|
140
|
+
"content": {
|
141
|
+
"source": "texte original",
|
142
|
+
"target": "résumé traduit en #{target_name}"
|
143
|
+
},
|
144
|
+
"metadata": {
|
145
|
+
"source_language": "#{source_locale}",
|
146
|
+
"target_language": "#{target_locale}",
|
147
|
+
"max_words": #{max_words},
|
148
|
+
"operation": "summarize_and_translate"
|
149
|
+
}
|
150
|
+
}
|
151
|
+
|
152
|
+
Texte à résumer et traduire :
|
153
|
+
#{text}
|
154
|
+
PROMPT
|
155
|
+
end
|
156
|
+
|
157
|
+
def clean_document_content(content)
|
158
|
+
return content if content.nil?
|
159
|
+
|
160
|
+
log_debug("Cleaning document content - original length: #{content.length}")
|
161
|
+
|
162
|
+
result = content
|
163
|
+
# Étape 1: Normaliser tous les espaces/tabs en espaces simples
|
164
|
+
.gsub(/[ \t]+/, " ")
|
165
|
+
# Étape 2: Supprimer les séparateurs de ligne (---, ----, etc.)
|
166
|
+
.gsub(/-{3,}/, "")
|
167
|
+
# Étape 3: Supprimer les lignes vides multiples (y compris celles avec espaces)
|
168
|
+
.gsub(/\n\s*\n+/, "\n")
|
169
|
+
# Étape 4: Supprimer espaces en début/fin de ligne
|
170
|
+
.gsub(/^[ \t]+|[ \t]+$/m, "")
|
171
|
+
# Étape 5: Nettoyer les espaces multiples créés par les suppressions précédentes
|
172
|
+
.gsub(/[ \t]+/, " ")
|
173
|
+
# Étape 6: Nettoyer le début et la fin
|
174
|
+
.strip
|
175
|
+
|
176
|
+
log_debug("Text cleaned - new length: #{result.length}")
|
177
|
+
result
|
178
|
+
end
|
179
|
+
|
180
|
+
def validate_summarize_inputs!(text, language, max_words)
|
181
|
+
raise ArgumentError, "Text cannot be nil or empty" if text.nil? || text.empty?
|
182
|
+
raise ArgumentError, "Language cannot be nil" if language.nil?
|
183
|
+
raise ArgumentError, "Max words must be a positive integer" unless max_words.is_a?(Integer) && max_words.positive?
|
184
|
+
end
|
185
|
+
|
186
|
+
def validate_summarize_translate_inputs!(text, from, to, max_words)
|
187
|
+
validate_summarize_inputs!(text, to, max_words)
|
188
|
+
raise ArgumentError, "Source language cannot be nil" if from.nil?
|
189
|
+
end
|
190
|
+
|
191
|
+
def validate_multiple_summarize_inputs!(text, languages, max_words)
|
192
|
+
languages_array = Array(languages)
|
193
|
+
first_language = languages_array.first || "fr"
|
194
|
+
validate_summarize_inputs!(text, first_language, max_words)
|
195
|
+
raise ArgumentError, "Languages array cannot be empty" if languages_array.empty?
|
196
|
+
end
|
197
|
+
|
198
|
+
def validate_tiered_inputs!(text, language, short, medium, long)
|
199
|
+
validate_summarize_inputs!(text, language, short)
|
200
|
+
raise ArgumentError, "Medium length must be greater than short" unless medium > short
|
201
|
+
raise ArgumentError, "Long length must be greater than medium" unless long > medium
|
202
|
+
end
|
203
|
+
|
204
|
+
def log_retry(error, attempt, wait_time, locale)
|
205
|
+
message = "Summary retry #{attempt}/#{DEFAULT_RETRY_COUNT} for #{locale} in #{wait_time}s: #{error.message}"
|
206
|
+
# Log une seule fois par locale et type d'erreur
|
207
|
+
Logger.warn_once(message, key: "summary_retry_#{locale}_#{error.class.name}", sensitive: false, ttl: 120)
|
208
|
+
end
|
209
|
+
|
210
|
+
def log_rate_limit_hit(operation, locale)
|
211
|
+
message = "Rate limit hit for #{operation} in #{locale}, retrying..."
|
212
|
+
# Log une seule fois par opération et locale
|
213
|
+
Logger.warn_once(message, key: "summary_rate_limit_#{operation}_#{locale}", sensitive: false, ttl: 300)
|
214
|
+
end
|
215
|
+
|
216
|
+
def log_debug(message)
|
217
|
+
# Log de debug seulement si mode verbose activé
|
218
|
+
Logger.debug_if_verbose(message, sensitive: false)
|
219
|
+
|
220
|
+
# Pour les tests, permettre un output dans stdout si nécessaire
|
221
|
+
return unless ENV["MISTRAL_TRANSLATOR_TEST_OUTPUT"] == "true"
|
222
|
+
|
223
|
+
puts "[MistralTranslator] #{message}"
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|