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.
@@ -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