llm_chain 0.5.1 → 0.5.3

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,349 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+
5
+ module LLMChain
6
+ class ConfigurationValidator
7
+ class ValidationError < Error; end
8
+ class ValidationWarning < StandardError; end
9
+
10
+ def self.validate_chain_config!(model: nil, **options)
11
+ new.validate_chain_config!(model: model, **options)
12
+ end
13
+
14
+ def validate_chain_config!(model: nil, **options)
15
+ @warnings = []
16
+
17
+ begin
18
+ validate_model!(model) if model
19
+ validate_client_availability!(model) if model
20
+ validate_tools!(options[:tools]) if options[:tools]
21
+ validate_memory!(options[:memory]) if options[:memory]
22
+ validate_retriever!(options[:retriever]) if options[:retriever]
23
+
24
+ # Выводим предупреждения, если есть
25
+ @warnings.each { |warning| warn_user(warning) } if @warnings.any?
26
+
27
+ true
28
+ rescue => e
29
+ raise ValidationError, "Configuration validation failed: #{e.message}"
30
+ end
31
+ end
32
+
33
+ def self.validate_environment
34
+ new.validate_environment
35
+ end
36
+
37
+ def validate_environment
38
+ @warnings = []
39
+ results = {}
40
+
41
+ results[:ollama] = check_ollama_availability
42
+ results[:ruby] = check_ruby_version
43
+ results[:python] = check_python_availability
44
+ results[:node] = check_node_availability
45
+ results[:internet] = check_internet_connectivity
46
+ results[:apis] = check_api_keys
47
+
48
+ results[:warnings] = @warnings
49
+ results
50
+ end
51
+
52
+ private
53
+
54
+ def validate_model!(model)
55
+ return if model.nil?
56
+
57
+ case model.to_s
58
+ when /^gpt/
59
+ validate_openai_requirements!(model)
60
+ when /qwen|llama|gemma/
61
+ validate_ollama_requirements!(model)
62
+ else
63
+ add_warning("Unknown model type: #{model}. Proceeding with default settings.")
64
+ end
65
+ end
66
+
67
+ def validate_openai_requirements!(model)
68
+ api_key = ENV['OPENAI_API_KEY']
69
+ unless api_key
70
+ raise ValidationError, "OpenAI API key required for model '#{model}'. Set OPENAI_API_KEY environment variable."
71
+ end
72
+
73
+ if api_key.length < 20
74
+ raise ValidationError, "OpenAI API key appears to be invalid (too short)."
75
+ end
76
+
77
+ # Проверяем доступность OpenAI API
78
+ begin
79
+ uri = URI('https://api.openai.com/v1/models')
80
+ http = Net::HTTP.new(uri.host, uri.port)
81
+ http.use_ssl = true
82
+ http.open_timeout = 5
83
+ http.read_timeout = 5
84
+
85
+ request = Net::HTTP::Get.new(uri)
86
+ request['Authorization'] = "Bearer #{api_key}"
87
+
88
+ response = http.request(request)
89
+
90
+ case response.code
91
+ when '200'
92
+ # OK
93
+ when '401'
94
+ raise ValidationError, "OpenAI API key is invalid or expired."
95
+ when '429'
96
+ add_warning("OpenAI API rate limit reached. Service may be temporarily unavailable.")
97
+ else
98
+ add_warning("OpenAI API returned status #{response.code}. Service may be temporarily unavailable.")
99
+ end
100
+ rescue => e
101
+ add_warning("Cannot verify OpenAI API availability: #{e.message}")
102
+ end
103
+ end
104
+
105
+ def validate_ollama_requirements!(model)
106
+ unless check_ollama_availability
107
+ raise ValidationError, "Ollama is not running. Please start Ollama server with: ollama serve"
108
+ end
109
+
110
+ unless model_available_in_ollama?(model)
111
+ raise ValidationError, "Model '#{model}' not found in Ollama. Available models: #{list_ollama_models.join(', ')}"
112
+ end
113
+ end
114
+
115
+ def validate_client_availability!(model)
116
+ case model.to_s
117
+ when /qwen|llama|gemma/
118
+ unless check_ollama_availability
119
+ raise ValidationError, "Ollama server is not running for model '#{model}'"
120
+ end
121
+ end
122
+ end
123
+
124
+ def validate_tools!(tools)
125
+ return unless tools
126
+
127
+ if tools.respond_to?(:tools) # ToolManager
128
+ tools.tools.each { |tool| validate_single_tool!(tool) }
129
+ elsif tools.is_a?(Array)
130
+ tools.each { |tool| validate_single_tool!(tool) }
131
+ else
132
+ validate_single_tool!(tools)
133
+ end
134
+ end
135
+
136
+ def validate_single_tool!(tool)
137
+ case tool.class.name
138
+ when /WebSearch/
139
+ validate_web_search_tool!(tool)
140
+ when /CodeInterpreter/
141
+ validate_code_interpreter_tool!(tool)
142
+ when /Calculator/
143
+ # Calculator не требует дополнительной валидации
144
+ end
145
+ end
146
+
147
+ def validate_web_search_tool!(tool)
148
+ # Проверяем доступность Google Search API
149
+ if ENV['GOOGLE_API_KEY'] && ENV['GOOGLE_SEARCH_ENGINE_ID']
150
+ # Есть API ключи, но проверим их валидность
151
+ begin
152
+ # Простая проверка доступности
153
+ uri = URI('https://www.googleapis.com/customsearch/v1')
154
+ http = Net::HTTP.new(uri.host, uri.port)
155
+ http.use_ssl = true
156
+ http.open_timeout = 3
157
+ http.read_timeout = 3
158
+
159
+ response = http.get('/')
160
+ # Если получили любой ответ, значит API доступен
161
+ rescue => e
162
+ add_warning("Google Search API may be unavailable: #{e.message}")
163
+ end
164
+ else
165
+ add_warning("Google Search API not configured. Search will use fallback methods.")
166
+ end
167
+
168
+ # Проверяем доступность интернета для fallback поиска
169
+ unless check_internet_connectivity
170
+ add_warning("No internet connection detected. Search functionality will be limited.")
171
+ end
172
+ end
173
+
174
+ def validate_code_interpreter_tool!(tool)
175
+ # Проверяем доступность языков программирования
176
+ languages = tool.instance_variable_get(:@allowed_languages) || ['ruby']
177
+
178
+ languages.each do |lang|
179
+ case lang
180
+ when 'ruby'
181
+ unless check_ruby_version
182
+ add_warning("Ruby interpreter not found or outdated.")
183
+ end
184
+ when 'python'
185
+ unless check_python_availability
186
+ add_warning("Python interpreter not found.")
187
+ end
188
+ when 'javascript'
189
+ unless check_node_availability
190
+ add_warning("Node.js interpreter not found.")
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ def validate_memory!(memory)
197
+ return unless memory
198
+
199
+ case memory.class.name
200
+ when /Redis/
201
+ validate_redis_memory!(memory)
202
+ when /Array/
203
+ # Array memory не требует дополнительной валидации
204
+ end
205
+ end
206
+
207
+ def validate_redis_memory!(memory)
208
+ begin
209
+ # Проверяем подключение к Redis
210
+ redis_client = memory.instance_variable_get(:@redis) || memory.redis
211
+ if redis_client.respond_to?(:ping)
212
+ redis_client.ping
213
+ end
214
+ rescue => e
215
+ raise ValidationError, "Redis connection failed: #{e.message}"
216
+ end
217
+ end
218
+
219
+ def validate_retriever!(retriever)
220
+ return unless retriever
221
+ return if retriever == false
222
+
223
+ case retriever.class.name
224
+ when /Weaviate/
225
+ validate_weaviate_retriever!(retriever)
226
+ end
227
+ end
228
+
229
+ def validate_weaviate_retriever!(retriever)
230
+ # Проверяем доступность Weaviate
231
+ begin
232
+ # Попытка подключения к Weaviate
233
+ uri = URI('http://localhost:8080/v1/.well-known/ready')
234
+ response = Net::HTTP.get_response(uri)
235
+
236
+ unless response.code == '200'
237
+ raise ValidationError, "Weaviate server is not ready. Please start Weaviate."
238
+ end
239
+ rescue => e
240
+ raise ValidationError, "Cannot connect to Weaviate: #{e.message}"
241
+ end
242
+ end
243
+
244
+ # Вспомогательные методы для проверки системы
245
+
246
+ def check_ollama_availability
247
+ begin
248
+ uri = URI('http://localhost:11434/api/tags')
249
+ response = Net::HTTP.get_response(uri)
250
+ response.code == '200'
251
+ rescue
252
+ false
253
+ end
254
+ end
255
+
256
+ def model_available_in_ollama?(model)
257
+ begin
258
+ uri = URI('http://localhost:11434/api/tags')
259
+ response = Net::HTTP.get_response(uri)
260
+ return false unless response.code == '200'
261
+
262
+ data = JSON.parse(response.body)
263
+ models = data['models'] || []
264
+ models.any? { |m| m['name'].include?(model.to_s.split(':').first) }
265
+ rescue
266
+ false
267
+ end
268
+ end
269
+
270
+ def list_ollama_models
271
+ begin
272
+ uri = URI('http://localhost:11434/api/tags')
273
+ response = Net::HTTP.get_response(uri)
274
+ return [] unless response.code == '200'
275
+
276
+ data = JSON.parse(response.body)
277
+ models = data['models'] || []
278
+ models.map { |m| m['name'] }
279
+ rescue
280
+ []
281
+ end
282
+ end
283
+
284
+ def check_ruby_version
285
+ begin
286
+ version = RUBY_VERSION
287
+ major, minor, patch = version.split('.').map(&:to_i)
288
+
289
+ # Требуем Ruby >= 3.1.0
290
+ if major > 3 || (major == 3 && minor >= 1)
291
+ true
292
+ else
293
+ add_warning("Ruby version #{version} detected. Minimum required: 3.1.0")
294
+ false
295
+ end
296
+ rescue
297
+ false
298
+ end
299
+ end
300
+
301
+ def check_python_availability
302
+ begin
303
+ output = `python3 --version 2>&1`
304
+ $?.success? && output.include?('Python')
305
+ rescue
306
+ false
307
+ end
308
+ end
309
+
310
+ def check_node_availability
311
+ begin
312
+ output = `node --version 2>&1`
313
+ $?.success? && output.include?('v')
314
+ rescue
315
+ false
316
+ end
317
+ end
318
+
319
+ def check_internet_connectivity
320
+ begin
321
+ require 'socket'
322
+ Socket.tcp("8.8.8.8", 53, connect_timeout: 3) {}
323
+ true
324
+ rescue
325
+ false
326
+ end
327
+ end
328
+
329
+ def check_api_keys
330
+ keys = {}
331
+ keys[:openai] = !ENV['OPENAI_API_KEY'].nil?
332
+ keys[:google_search] = !ENV['GOOGLE_API_KEY'].nil? && !ENV['GOOGLE_SEARCH_ENGINE_ID'].nil?
333
+ keys[:bing_search] = !ENV['BING_API_KEY'].nil?
334
+ keys
335
+ end
336
+
337
+ def add_warning(message)
338
+ @warnings << message
339
+ end
340
+
341
+ def warn_user(message)
342
+ if defined?(Rails) && Rails.logger
343
+ Rails.logger.warn "[LLMChain] #{message}"
344
+ else
345
+ warn "[LLMChain] Warning: #{message}"
346
+ end
347
+ end
348
+ end
349
+ end
@@ -108,41 +108,192 @@ module LLMChain
108
108
  end
109
109
 
110
110
  def extract_code(prompt)
111
- # Ищем код в блоках ```
112
- code_block = prompt.match(/```(?:ruby|python|javascript|js)?\s*\n(.*?)\n```/m)
113
- return code_block[1].strip if code_block
111
+ # Нормализуем line endings
112
+ normalized_prompt = normalize_line_endings(prompt)
113
+
114
+ # 1. Пробуем различные паттерны markdown блоков
115
+ code = extract_markdown_code_blocks(normalized_prompt)
116
+ return clean_code(code) if code && !code.empty?
117
+
118
+ # 2. Ищем код после ключевых команд в одной строке
119
+ code = extract_inline_code_commands(normalized_prompt)
120
+ return clean_code(code) if code && !code.empty?
121
+
122
+ # 3. Ищем код после ключевых слов в разных строках
123
+ code = extract_multiline_code_blocks(normalized_prompt)
124
+ return clean_code(code) if code && !code.empty?
125
+
126
+ # 4. Ищем строки, которые выглядят как код
127
+ code = extract_code_like_lines(normalized_prompt)
128
+ return clean_code(code) if code && !code.empty?
129
+
130
+ # 5. Последняя попытка - весь текст после первого кода
131
+ code = extract_fallback_code(normalized_prompt)
132
+ clean_code(code)
133
+ end
134
+
135
+ private
136
+
137
+ def normalize_line_endings(text)
138
+ text.gsub(/\r\n/, "\n").gsub(/\r/, "\n")
139
+ end
114
140
 
115
- # Ищем код после ключевых слов в той же строке (например, "Execute code: puts ...")
116
- execute_match = prompt.match(/execute\s+code:\s*(.+)/i)
117
- return execute_match[1].strip if execute_match
141
+ def extract_markdown_code_blocks(prompt)
142
+ # Различные паттерны для markdown блоков
143
+ patterns = [
144
+ # Стандартный markdown с указанием языка
145
+ /```(?:ruby|python|javascript|js)\s*\n(.*?)\n```/mi,
146
+ # Markdown без указания языка
147
+ /```\s*\n(.*?)\n```/mi,
148
+ # Markdown с любым языком
149
+ /```\w*\s*\n(.*?)\n```/mi,
150
+ # Тильды вместо backticks
151
+ /~~~(?:ruby|python|javascript|js)?\s*\n(.*?)\n~~~/mi,
152
+ # Без переносов строк
153
+ /```(?:ruby|python|javascript|js)?(.*?)```/mi,
154
+ # Четыре пробела (indented code blocks)
155
+ /^ (.+)$/m
156
+ ]
157
+
158
+ patterns.each do |pattern|
159
+ match = prompt.match(pattern)
160
+ return match[1] if match && match[1].strip.length > 0
161
+ end
162
+
163
+ nil
164
+ end
118
165
 
119
- run_match = prompt.match(/run\s+code:\s*(.+)/i)
120
- return run_match[1].strip if run_match
166
+ def extract_inline_code_commands(prompt)
167
+ # Команды в одной строке
168
+ inline_patterns = [
169
+ /execute\s+code:\s*(.+)/i,
170
+ /run\s+code:\s*(.+)/i,
171
+ /run\s+this:\s*(.+)/i,
172
+ /execute:\s*(.+)/i,
173
+ /run:\s*(.+)/i,
174
+ /code:\s*(.+)/i
175
+ ]
176
+
177
+ inline_patterns.each do |pattern|
178
+ match = prompt.match(pattern)
179
+ return match[1] if match && match[1].strip.length > 0
180
+ end
181
+
182
+ nil
183
+ end
121
184
 
122
- # Ищем код после ключевых слов в разных строках
185
+ def extract_multiline_code_blocks(prompt)
186
+ lines = prompt.split("\n")
187
+
123
188
  KEYWORDS.each do |keyword|
124
- if prompt.downcase.include?(keyword)
125
- lines = prompt.split("\n")
126
- keyword_line = lines.find_index { |line| line.downcase.include?(keyword) }
127
- if keyword_line
128
- # Берем строки после ключевого слова
129
- code_lines = lines[(keyword_line + 1)..-1]
130
- code = code_lines&.join("\n")&.strip
131
- return code if code && !code.empty?
189
+ keyword_line_index = lines.find_index { |line| line.downcase.include?(keyword.downcase) }
190
+ next unless keyword_line_index
191
+
192
+ # Берем строки после ключевого слова
193
+ code_lines = lines[(keyword_line_index + 1)..-1]
194
+ next unless code_lines
195
+
196
+ # Найдем первую непустую строку
197
+ first_code_line = code_lines.find_index { |line| !line.strip.empty? }
198
+ next unless first_code_line
199
+
200
+ # Берем все строки начиная с первой непустой
201
+ relevant_lines = code_lines[first_code_line..-1]
202
+
203
+ # Определяем отступ первой строки кода
204
+ first_line = relevant_lines.first
205
+ indent = first_line.match(/^(\s*)/)[1].length
206
+
207
+ # Собираем все строки с таким же или большим отступом
208
+ code_block = []
209
+ relevant_lines.each do |line|
210
+ if line.strip.empty?
211
+ code_block << "" # Сохраняем пустые строки
212
+ elsif line.match(/^(\s*)/)[1].length >= indent
213
+ code_block << line
214
+ else
215
+ break # Прекращаем при уменьшении отступа
132
216
  end
133
217
  end
218
+
219
+ return code_block.join("\n") if code_block.any? { |line| !line.strip.empty? }
134
220
  end
221
+
222
+ nil
223
+ end
224
+
225
+ def extract_code_like_lines(prompt)
226
+ lines = prompt.split("\n")
227
+
228
+ code_lines = lines.select do |line|
229
+ stripped = line.strip
230
+ next false if stripped.empty?
231
+
232
+ # Проверяем различные паттерны кода
233
+ stripped.match?(/^(def|class|function|var|let|const|print|puts|console\.log)/i) ||
234
+ stripped.match?(/^\w+\s*[=+\-*\/]\s*/) ||
235
+ stripped.match?(/^\s*(if|for|while|return|import|require)[\s(]/i) ||
236
+ stripped.match?(/puts\s+/) ||
237
+ stripped.match?(/print\s*\(/) ||
238
+ stripped.match?(/^\w+\(.*\)/) ||
239
+ stripped.match?(/^\s*#.*/) || # Комментарии
240
+ stripped.match?(/^\s*\/\/.*/) || # JS комментарии
241
+ stripped.match?(/^\s*\/\*.*\*\//) # Блочные комментарии
242
+ end
243
+
244
+ code_lines.join("\n") if code_lines.any?
245
+ end
135
246
 
136
- # Ищем строки, которые выглядят как код
137
- code_lines = prompt.split("\n").select do |line|
138
- line.strip.match?(/^(def|class|function|var|let|const|print|puts|console\.log)/i) ||
139
- line.strip.match?(/^\w+\s*[=+\-*\/]\s*/) ||
140
- line.strip.match?(/^\s*(if|for|while|return)[\s(]/i) ||
141
- line.strip.match?(/puts\s+/) ||
142
- line.strip.match?(/print\s*\(/)
247
+ def extract_fallback_code(prompt)
248
+ # Последняя попытка - ищем что-то похожее на код
249
+ lines = prompt.split("\n")
250
+
251
+ # Найдем первую строку, которая выглядит как код
252
+ start_index = lines.find_index do |line|
253
+ stripped = line.strip
254
+ stripped.match?(/^(def|class|function|puts|print|console\.log|var|let|const)/i) ||
255
+ stripped.include?('=') ||
256
+ stripped.include?(';')
257
+ end
258
+
259
+ return nil unless start_index
260
+
261
+ # Берем все строки после найденной
262
+ code_lines = lines[start_index..-1]
263
+
264
+ # Останавливаемся на первой строке, которая явно не код
265
+ end_index = code_lines.find_index do |line|
266
+ stripped = line.strip
267
+ stripped.match?(/^(что|как|где|когда|зачем|почему|what|how|where|when|why)/i) ||
268
+ stripped.length > 100 # Слишком длинная строка
143
269
  end
270
+
271
+ relevant_lines = end_index ? code_lines[0...end_index] : code_lines
272
+ relevant_lines.join("\n")
273
+ end
144
274
 
145
- code_lines.join("\n")
275
+ def clean_code(code)
276
+ return "" unless code
277
+
278
+ lines = code.strip.lines
279
+
280
+ # Удаляем только комментарии, которые не являются частью кода
281
+ cleaned_lines = lines.reject do |line|
282
+ stripped = line.strip
283
+ # Удаляем только строки, которые содержат ТОЛЬКО комментарии
284
+ stripped.match?(/^\s*#[^{]*$/) || # Ruby комментарии (но не интерполяция)
285
+ stripped.match?(/^\s*\/\/.*$/) || # JS комментарии
286
+ stripped.match?(/^\s*\/\*.*\*\/\s*$/) # Блочные комментарии
287
+ end
288
+
289
+ # Убираем пустые строки в начале и конце, но сохраняем внутри
290
+ start_index = cleaned_lines.find_index { |line| !line.strip.empty? }
291
+ return "" unless start_index
292
+
293
+ end_index = cleaned_lines.rindex { |line| !line.strip.empty? }
294
+ return "" unless end_index
295
+
296
+ cleaned_lines[start_index..end_index].join
146
297
  end
147
298
 
148
299
  def detect_language(code, prompt)