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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +195 -14
- data/exe/llm-chain +126 -0
- data/lib/llm_chain/chain.rb +16 -1
- data/lib/llm_chain/configuration_validator.rb +349 -0
- data/lib/llm_chain/tools/code_interpreter.rb +176 -25
- data/lib/llm_chain/tools/web_search.rb +275 -76
- data/lib/llm_chain/version.rb +1 -1
- data/lib/llm_chain.rb +42 -1
- metadata +6 -3
@@ -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
|
-
|
113
|
-
|
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
|
-
|
116
|
-
|
117
|
-
|
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
|
-
|
120
|
-
|
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
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
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
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
line.strip
|
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
|
-
|
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)
|