llm_chain 0.5.1 → 0.5.2
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 +24 -1
- data/README.md +162 -14
- 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 +3 -2
@@ -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)
|
@@ -46,13 +46,15 @@ module LLMChain
|
|
46
46
|
num_results = extract_num_results(prompt)
|
47
47
|
|
48
48
|
begin
|
49
|
-
results =
|
49
|
+
results = perform_search_with_retry(query, num_results)
|
50
50
|
format_search_results(query, results)
|
51
51
|
rescue => e
|
52
|
+
log_error("Search failed for '#{query}'", e)
|
52
53
|
{
|
53
54
|
query: query,
|
54
55
|
error: e.message,
|
55
|
-
|
56
|
+
results: [],
|
57
|
+
formatted: "Search unavailable for '#{query}'. Please try again later or rephrase your query."
|
56
58
|
}
|
57
59
|
end
|
58
60
|
end
|
@@ -97,6 +99,31 @@ module LLMChain
|
|
97
99
|
5 # default
|
98
100
|
end
|
99
101
|
|
102
|
+
def perform_search_with_retry(query, num_results, max_retries: 3)
|
103
|
+
retries = 0
|
104
|
+
last_error = nil
|
105
|
+
|
106
|
+
begin
|
107
|
+
perform_search(query, num_results)
|
108
|
+
rescue => e
|
109
|
+
last_error = e
|
110
|
+
retries += 1
|
111
|
+
|
112
|
+
if retries <= max_retries && retryable_error?(e)
|
113
|
+
sleep_time = [0.5 * (2 ** (retries - 1)), 5.0].min # exponential backoff, max 5 seconds
|
114
|
+
log_retry("Retrying search (#{retries}/#{max_retries}) after #{sleep_time}s", e)
|
115
|
+
sleep(sleep_time)
|
116
|
+
retry
|
117
|
+
else
|
118
|
+
log_error("Search failed after #{retries} attempts", e)
|
119
|
+
# Fallback to hardcoded results as last resort
|
120
|
+
hardcoded = get_hardcoded_results(query)
|
121
|
+
return hardcoded unless hardcoded.empty?
|
122
|
+
raise e
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
100
127
|
def perform_search(query, num_results)
|
101
128
|
case @search_engine
|
102
129
|
when :google
|
@@ -115,25 +142,52 @@ module LLMChain
|
|
115
142
|
def fallback_search(query, num_results)
|
116
143
|
return [] if num_results <= 0
|
117
144
|
|
118
|
-
#
|
119
|
-
# для популярных запросов
|
145
|
+
# Сначала пробуем заранее заготовленные данные для популярных запросов
|
120
146
|
hardcoded_results = get_hardcoded_results(query)
|
121
147
|
return hardcoded_results unless hardcoded_results.empty?
|
122
148
|
|
123
|
-
#
|
124
|
-
|
125
|
-
uri.query = URI.encode_www_form(q: query)
|
126
|
-
|
127
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
128
|
-
http.use_ssl = true
|
129
|
-
http.open_timeout = 10
|
130
|
-
http.read_timeout = 10
|
149
|
+
# Проверяем, доступен ли интернет
|
150
|
+
return offline_fallback_results(query) if offline_mode?
|
131
151
|
|
132
|
-
|
133
|
-
|
152
|
+
begin
|
153
|
+
results = search_duckduckgo_html(query, num_results)
|
154
|
+
return results unless results.empty?
|
155
|
+
|
156
|
+
# Если DuckDuckGo не дал результатов, возвращаем заглушку
|
157
|
+
offline_fallback_results(query)
|
158
|
+
rescue => e
|
159
|
+
log_error("Fallback search failed", e)
|
160
|
+
offline_fallback_results(query)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def search_duckduckgo_html(query, num_results)
|
165
|
+
require 'timeout'
|
134
166
|
|
135
|
-
|
136
|
-
|
167
|
+
Timeout.timeout(15) do
|
168
|
+
uri = URI("https://html.duckduckgo.com/html/")
|
169
|
+
uri.query = URI.encode_www_form(q: query)
|
170
|
+
|
171
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
172
|
+
http.use_ssl = true
|
173
|
+
http.open_timeout = 8
|
174
|
+
http.read_timeout = 10
|
175
|
+
|
176
|
+
response = http.get(uri.request_uri)
|
177
|
+
|
178
|
+
unless response.code == '200'
|
179
|
+
log_error("DuckDuckGo returned #{response.code}", StandardError.new(response.body))
|
180
|
+
return []
|
181
|
+
end
|
182
|
+
|
183
|
+
parse_duckduckgo_results(response.body, num_results)
|
184
|
+
end
|
185
|
+
rescue Timeout::Error
|
186
|
+
log_error("DuckDuckGo search timeout", Timeout::Error.new("Request took longer than 15 seconds"))
|
187
|
+
[]
|
188
|
+
end
|
189
|
+
|
190
|
+
def parse_duckduckgo_results(html, num_results)
|
137
191
|
results = []
|
138
192
|
|
139
193
|
# Ищем различные паттерны результатов
|
@@ -147,9 +201,10 @@ module LLMChain
|
|
147
201
|
html.scan(pattern) do |url, title|
|
148
202
|
next if results.length >= num_results
|
149
203
|
next if url.include?('duckduckgo.com/y.js') # Skip tracking links
|
204
|
+
next if title.strip.empty?
|
150
205
|
|
151
206
|
results << {
|
152
|
-
title: title
|
207
|
+
title: clean_html_text(title),
|
153
208
|
url: clean_url(url),
|
154
209
|
snippet: "Search result from DuckDuckGo"
|
155
210
|
}
|
@@ -158,14 +213,37 @@ module LLMChain
|
|
158
213
|
end
|
159
214
|
|
160
215
|
results
|
161
|
-
|
216
|
+
end
|
217
|
+
|
218
|
+
def offline_fallback_results(query)
|
162
219
|
[{
|
163
220
|
title: "Search unavailable",
|
164
221
|
url: "",
|
165
|
-
snippet: "Unable to perform web search at this time. Query: #{query}"
|
222
|
+
snippet: "Unable to perform web search at this time. Query: #{query}. Please check your internet connection."
|
166
223
|
}]
|
167
224
|
end
|
168
225
|
|
226
|
+
def offline_mode?
|
227
|
+
# Простая проверка доступности интернета
|
228
|
+
begin
|
229
|
+
require 'socket'
|
230
|
+
Socket.tcp("8.8.8.8", 53, connect_timeout: 3) {}
|
231
|
+
false
|
232
|
+
rescue
|
233
|
+
true
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def clean_html_text(text)
|
238
|
+
text.strip
|
239
|
+
.gsub(/</, '<')
|
240
|
+
.gsub(/>/, '>')
|
241
|
+
.gsub(/&/, '&')
|
242
|
+
.gsub(/"/, '"')
|
243
|
+
.gsub(/'/, "'")
|
244
|
+
.gsub(/\s+/, ' ')
|
245
|
+
end
|
246
|
+
|
169
247
|
# Заранее заготовленные результаты для популярных запросов
|
170
248
|
def get_hardcoded_results(query)
|
171
249
|
ruby_version_queries = [
|
@@ -203,76 +281,151 @@ module LLMChain
|
|
203
281
|
def search_google(query, num_results)
|
204
282
|
# Google Custom Search API (требует API ключ)
|
205
283
|
unless @api_key
|
284
|
+
log_error("Google API key not provided, using fallback", StandardError.new("No API key"))
|
206
285
|
return fallback_search(query, num_results)
|
207
286
|
end
|
208
287
|
|
209
|
-
search_engine_id = ENV['GOOGLE_SEARCH_ENGINE_ID'] || ENV['GOOGLE_CX']
|
210
|
-
|
211
|
-
|
212
|
-
params = {
|
213
|
-
key: @api_key,
|
214
|
-
cx: search_engine_id,
|
215
|
-
q: query,
|
216
|
-
num: [num_results, 10].min
|
217
|
-
}
|
218
|
-
uri.query = URI.encode_www_form(params)
|
219
|
-
|
220
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
221
|
-
http.use_ssl = true
|
222
|
-
http.open_timeout = 10
|
223
|
-
http.read_timeout = 10
|
224
|
-
|
225
|
-
response = http.get(uri.request_uri)
|
226
|
-
|
227
|
-
unless response.code == '200'
|
288
|
+
search_engine_id = ENV['GOOGLE_SEARCH_ENGINE_ID'] || ENV['GOOGLE_CX']
|
289
|
+
unless search_engine_id && search_engine_id != 'your-search-engine-id'
|
290
|
+
log_error("Google Search Engine ID not configured", StandardError.new("Missing GOOGLE_SEARCH_ENGINE_ID"))
|
228
291
|
return fallback_search(query, num_results)
|
229
292
|
end
|
230
|
-
|
231
|
-
data = JSON.parse(response.body)
|
232
293
|
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
294
|
+
begin
|
295
|
+
require 'timeout'
|
296
|
+
|
297
|
+
Timeout.timeout(20) do
|
298
|
+
uri = URI("https://www.googleapis.com/customsearch/v1")
|
299
|
+
params = {
|
300
|
+
key: @api_key,
|
301
|
+
cx: search_engine_id,
|
302
|
+
q: query,
|
303
|
+
num: [num_results, 10].min,
|
304
|
+
safe: 'active'
|
305
|
+
}
|
306
|
+
uri.query = URI.encode_www_form(params)
|
307
|
+
|
308
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
309
|
+
http.use_ssl = true
|
310
|
+
http.open_timeout = 8
|
311
|
+
http.read_timeout = 12
|
312
|
+
|
313
|
+
response = http.get(uri.request_uri)
|
314
|
+
|
315
|
+
case response.code
|
316
|
+
when '200'
|
317
|
+
data = JSON.parse(response.body)
|
318
|
+
|
319
|
+
if data['error']
|
320
|
+
log_error("Google API error: #{data['error']['message']}", StandardError.new(data['error']['message']))
|
321
|
+
return fallback_search(query, num_results)
|
322
|
+
end
|
323
|
+
|
324
|
+
results = (data['items'] || []).map do |item|
|
325
|
+
{
|
326
|
+
title: item['title']&.strip || 'Untitled',
|
327
|
+
url: item['link'] || '',
|
328
|
+
snippet: item['snippet']&.strip || 'No description available'
|
329
|
+
}
|
330
|
+
end
|
331
|
+
|
332
|
+
# Если Google не вернул результатов, используем fallback
|
333
|
+
results.empty? ? fallback_search(query, num_results) : results
|
334
|
+
when '403'
|
335
|
+
log_error("Google API quota exceeded or invalid key", StandardError.new(response.body))
|
336
|
+
fallback_search(query, num_results)
|
337
|
+
when '400'
|
338
|
+
log_error("Google API bad request", StandardError.new(response.body))
|
339
|
+
fallback_search(query, num_results)
|
340
|
+
else
|
341
|
+
log_error("Google API returned #{response.code}", StandardError.new(response.body))
|
342
|
+
fallback_search(query, num_results)
|
343
|
+
end
|
344
|
+
end
|
345
|
+
rescue Timeout::Error
|
346
|
+
log_error("Google search timeout", Timeout::Error.new("Request took longer than 20 seconds"))
|
347
|
+
fallback_search(query, num_results)
|
348
|
+
rescue JSON::ParserError => e
|
349
|
+
log_error("Invalid JSON response from Google", e)
|
350
|
+
fallback_search(query, num_results)
|
351
|
+
rescue => e
|
352
|
+
log_error("Google search failed", e)
|
353
|
+
fallback_search(query, num_results)
|
239
354
|
end
|
240
|
-
|
241
|
-
# Если Google не вернул результатов, используем fallback
|
242
|
-
results.empty? ? fallback_search(query, num_results) : results
|
243
|
-
rescue => e
|
244
|
-
fallback_search(query, num_results)
|
245
355
|
end
|
246
356
|
|
247
357
|
def search_bing(query, num_results)
|
248
358
|
# Bing Web Search API (требует API ключ)
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
q: query,
|
254
|
-
count: [num_results, 20].min,
|
255
|
-
responseFilter: 'Webpages'
|
256
|
-
}
|
257
|
-
uri.query = URI.encode_www_form(params)
|
359
|
+
unless @api_key
|
360
|
+
log_error("Bing API key not provided, using fallback", StandardError.new("No API key"))
|
361
|
+
return fallback_search(query, num_results)
|
362
|
+
end
|
258
363
|
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
364
|
+
begin
|
365
|
+
require 'timeout'
|
366
|
+
|
367
|
+
Timeout.timeout(20) do
|
368
|
+
uri = URI("https://api.bing.microsoft.com/v7.0/search")
|
369
|
+
params = {
|
370
|
+
q: query,
|
371
|
+
count: [num_results, 20].min,
|
372
|
+
responseFilter: 'Webpages',
|
373
|
+
safeSearch: 'Moderate'
|
374
|
+
}
|
375
|
+
uri.query = URI.encode_www_form(params)
|
267
376
|
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
377
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
378
|
+
http.use_ssl = true
|
379
|
+
http.open_timeout = 8
|
380
|
+
http.read_timeout = 12
|
381
|
+
|
382
|
+
request = Net::HTTP::Get.new(uri)
|
383
|
+
request['Ocp-Apim-Subscription-Key'] = @api_key
|
384
|
+
request['User-Agent'] = 'LLMChain/1.0'
|
385
|
+
|
386
|
+
response = http.request(request)
|
387
|
+
|
388
|
+
case response.code
|
389
|
+
when '200'
|
390
|
+
data = JSON.parse(response.body)
|
391
|
+
|
392
|
+
if data['error']
|
393
|
+
log_error("Bing API error: #{data['error']['message']}", StandardError.new(data['error']['message']))
|
394
|
+
return fallback_search(query, num_results)
|
395
|
+
end
|
396
|
+
|
397
|
+
results = (data.dig('webPages', 'value') || []).map do |item|
|
398
|
+
{
|
399
|
+
title: item['name']&.strip || 'Untitled',
|
400
|
+
url: item['url'] || '',
|
401
|
+
snippet: item['snippet']&.strip || 'No description available'
|
402
|
+
}
|
403
|
+
end
|
404
|
+
|
405
|
+
results.empty? ? fallback_search(query, num_results) : results
|
406
|
+
when '401'
|
407
|
+
log_error("Bing API unauthorized - check your subscription key", StandardError.new(response.body))
|
408
|
+
fallback_search(query, num_results)
|
409
|
+
when '403'
|
410
|
+
log_error("Bing API quota exceeded", StandardError.new(response.body))
|
411
|
+
fallback_search(query, num_results)
|
412
|
+
when '429'
|
413
|
+
log_error("Bing API rate limit exceeded", StandardError.new(response.body))
|
414
|
+
fallback_search(query, num_results)
|
415
|
+
else
|
416
|
+
log_error("Bing API returned #{response.code}", StandardError.new(response.body))
|
417
|
+
fallback_search(query, num_results)
|
418
|
+
end
|
419
|
+
end
|
420
|
+
rescue Timeout::Error
|
421
|
+
log_error("Bing search timeout", Timeout::Error.new("Request took longer than 20 seconds"))
|
422
|
+
fallback_search(query, num_results)
|
423
|
+
rescue JSON::ParserError => e
|
424
|
+
log_error("Invalid JSON response from Bing", e)
|
425
|
+
fallback_search(query, num_results)
|
426
|
+
rescue => e
|
427
|
+
log_error("Bing search failed", e)
|
428
|
+
fallback_search(query, num_results)
|
276
429
|
end
|
277
430
|
end
|
278
431
|
|
@@ -300,6 +453,52 @@ module LLMChain
|
|
300
453
|
def required_parameters
|
301
454
|
['query']
|
302
455
|
end
|
456
|
+
|
457
|
+
private
|
458
|
+
|
459
|
+
def retryable_error?(error)
|
460
|
+
# Определяем, стоит ли повторять запрос при данной ошибке
|
461
|
+
case error
|
462
|
+
when Net::TimeoutError, Net::OpenTimeout, Net::ReadTimeout
|
463
|
+
true
|
464
|
+
when SocketError
|
465
|
+
# DNS ошибки обычно временные
|
466
|
+
true
|
467
|
+
when Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH
|
468
|
+
true
|
469
|
+
when Net::HTTPError
|
470
|
+
# Повторяем только для серверных ошибок (5xx)
|
471
|
+
error.message.match?(/5\d\d/)
|
472
|
+
else
|
473
|
+
false
|
474
|
+
end
|
475
|
+
end
|
476
|
+
|
477
|
+
def log_error(message, error)
|
478
|
+
return unless should_log?
|
479
|
+
|
480
|
+
if defined?(Rails) && Rails.logger
|
481
|
+
Rails.logger.error "[WebSearch] #{message}: #{error.class} - #{error.message}"
|
482
|
+
else
|
483
|
+
warn "[WebSearch] #{message}: #{error.class} - #{error.message}"
|
484
|
+
end
|
485
|
+
end
|
486
|
+
|
487
|
+
def log_retry(message, error)
|
488
|
+
return unless should_log?
|
489
|
+
|
490
|
+
if defined?(Rails) && Rails.logger
|
491
|
+
Rails.logger.warn "[WebSearch] #{message}: #{error.class} - #{error.message}"
|
492
|
+
else
|
493
|
+
warn "[WebSearch] #{message}: #{error.class} - #{error.message}"
|
494
|
+
end
|
495
|
+
end
|
496
|
+
|
497
|
+
def should_log?
|
498
|
+
ENV['LLM_CHAIN_DEBUG'] == 'true' ||
|
499
|
+
ENV['RAILS_ENV'] == 'development' ||
|
500
|
+
(defined?(Rails) && Rails.env.development?)
|
501
|
+
end
|
303
502
|
end
|
304
503
|
end
|
305
504
|
end
|
data/lib/llm_chain/version.rb
CHANGED