llm_chain 0.4.0 → 0.5.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 +4 -4
- data/README.md +466 -103
- data/examples/quick_demo.rb +93 -0
- data/examples/tools_example.rb +255 -0
- data/lib/llm_chain/chain.rb +19 -5
- data/lib/llm_chain/clients/qwen.rb +13 -1
- data/lib/llm_chain/tools/base_tool.rb +81 -0
- data/lib/llm_chain/tools/calculator.rb +143 -0
- data/lib/llm_chain/tools/code_interpreter.rb +233 -0
- data/lib/llm_chain/tools/tool_manager.rb +204 -0
- data/lib/llm_chain/tools/web_search.rb +255 -0
- data/lib/llm_chain/version.rb +1 -1
- data/lib/llm_chain.rb +5 -0
- metadata +9 -2
@@ -0,0 +1,233 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
require 'timeout'
|
3
|
+
|
4
|
+
module LLMChain
|
5
|
+
module Tools
|
6
|
+
class CodeInterpreter < BaseTool
|
7
|
+
KEYWORDS = %w[
|
8
|
+
code run execute script program
|
9
|
+
ruby python javascript
|
10
|
+
calculate compute
|
11
|
+
def class function
|
12
|
+
].freeze
|
13
|
+
|
14
|
+
SUPPORTED_LANGUAGES = %w[ruby python javascript].freeze
|
15
|
+
|
16
|
+
DANGEROUS_PATTERNS = [
|
17
|
+
/system\s*\(/i,
|
18
|
+
/exec\s*\(/i,
|
19
|
+
/`[^`]*`/,
|
20
|
+
/File\.(delete|rm|unlink)/i,
|
21
|
+
/Dir\.(delete|rmdir)/i,
|
22
|
+
/require\s+['"]net\/http['"]/i,
|
23
|
+
/require\s+['"]open-uri['"]/i,
|
24
|
+
/eval\s*\(/i,
|
25
|
+
/instance_eval/i,
|
26
|
+
/class_eval/i
|
27
|
+
].freeze
|
28
|
+
|
29
|
+
def initialize(timeout: 30, allowed_languages: SUPPORTED_LANGUAGES)
|
30
|
+
@timeout = timeout
|
31
|
+
@allowed_languages = allowed_languages
|
32
|
+
|
33
|
+
super(
|
34
|
+
name: "code_interpreter",
|
35
|
+
description: "Executes code safely in an isolated environment",
|
36
|
+
parameters: {
|
37
|
+
code: {
|
38
|
+
type: "string",
|
39
|
+
description: "Code to execute"
|
40
|
+
},
|
41
|
+
language: {
|
42
|
+
type: "string",
|
43
|
+
description: "Programming language (ruby, python, javascript)",
|
44
|
+
enum: @allowed_languages
|
45
|
+
}
|
46
|
+
}
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
def match?(prompt)
|
51
|
+
contains_keywords?(prompt, KEYWORDS) ||
|
52
|
+
contains_code_blocks?(prompt) ||
|
53
|
+
contains_function_definitions?(prompt)
|
54
|
+
end
|
55
|
+
|
56
|
+
def call(prompt, context: {})
|
57
|
+
code = extract_code(prompt)
|
58
|
+
language = detect_language(code, prompt)
|
59
|
+
|
60
|
+
return "No code found to execute" if code.empty?
|
61
|
+
return "Unsupported language: #{language}" unless @allowed_languages.include?(language)
|
62
|
+
|
63
|
+
begin
|
64
|
+
if safe_to_execute?(code)
|
65
|
+
result = execute_code(code, language)
|
66
|
+
{
|
67
|
+
code: code,
|
68
|
+
language: language,
|
69
|
+
result: result,
|
70
|
+
formatted: format_execution_result(code, language, result)
|
71
|
+
}
|
72
|
+
else
|
73
|
+
{
|
74
|
+
code: code,
|
75
|
+
language: language,
|
76
|
+
error: "Code contains potentially dangerous operations",
|
77
|
+
formatted: "Cannot execute: Code contains potentially dangerous operations"
|
78
|
+
}
|
79
|
+
end
|
80
|
+
rescue => e
|
81
|
+
{
|
82
|
+
code: code,
|
83
|
+
language: language,
|
84
|
+
error: e.message,
|
85
|
+
formatted: "Execution error: #{e.message}"
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def extract_parameters(prompt)
|
91
|
+
code = extract_code(prompt)
|
92
|
+
{
|
93
|
+
code: code,
|
94
|
+
language: detect_language(code, prompt)
|
95
|
+
}
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def contains_code_blocks?(prompt)
|
101
|
+
prompt.include?('```') ||
|
102
|
+
prompt.match?(/^\s*def\s+\w+/m) ||
|
103
|
+
prompt.match?(/^\s*class\s+\w+/m)
|
104
|
+
end
|
105
|
+
|
106
|
+
def contains_function_definitions?(prompt)
|
107
|
+
prompt.match?(/\b(def|function|class)\s+\w+/i)
|
108
|
+
end
|
109
|
+
|
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
|
114
|
+
|
115
|
+
# Ищем код после ключевых слов
|
116
|
+
KEYWORDS.each do |keyword|
|
117
|
+
if prompt.downcase.include?(keyword)
|
118
|
+
lines = prompt.split("\n")
|
119
|
+
keyword_line = lines.find_index { |line| line.downcase.include?(keyword) }
|
120
|
+
if keyword_line
|
121
|
+
# Берем строки после ключевого слова
|
122
|
+
code_lines = lines[(keyword_line + 1)..-1]
|
123
|
+
code = code_lines&.join("\n")&.strip
|
124
|
+
return code if code && !code.empty?
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Ищем строки, которые выглядят как код
|
130
|
+
code_lines = prompt.split("\n").select do |line|
|
131
|
+
line.strip.match?(/^(def|class|function|var|let|const|print|puts|console\.log)/i) ||
|
132
|
+
line.strip.match?(/^\w+\s*[=+\-*\/]\s*/) ||
|
133
|
+
line.strip.match?(/^\s*(if|for|while|return)[\s(]/i)
|
134
|
+
end
|
135
|
+
|
136
|
+
code_lines.join("\n")
|
137
|
+
end
|
138
|
+
|
139
|
+
def detect_language(code, prompt)
|
140
|
+
# Явное указание языка
|
141
|
+
return 'ruby' if prompt.match?(/```ruby/i) || prompt.include?('Ruby')
|
142
|
+
return 'python' if prompt.match?(/```python/i) || prompt.include?('Python')
|
143
|
+
return 'javascript' if prompt.match?(/```(javascript|js)/i) || prompt.include?('JavaScript')
|
144
|
+
|
145
|
+
# Определение по синтаксису
|
146
|
+
return 'ruby' if code.include?('puts') || code.include?('def ') || code.match?(/\bend\b/)
|
147
|
+
return 'python' if code.include?('print(') || code.match?(/def \w+\(.*\):/) || code.include?('import ')
|
148
|
+
return 'javascript' if code.include?('console.log') || code.include?('function ') || code.include?('var ') || code.include?('let ')
|
149
|
+
|
150
|
+
'ruby' # default
|
151
|
+
end
|
152
|
+
|
153
|
+
def safe_to_execute?(code)
|
154
|
+
DANGEROUS_PATTERNS.none? { |pattern| code.match?(pattern) }
|
155
|
+
end
|
156
|
+
|
157
|
+
def execute_code(code, language)
|
158
|
+
case language
|
159
|
+
when 'ruby'
|
160
|
+
execute_ruby(code)
|
161
|
+
when 'python'
|
162
|
+
execute_python(code)
|
163
|
+
when 'javascript'
|
164
|
+
execute_javascript(code)
|
165
|
+
else
|
166
|
+
raise "Unsupported language: #{language}"
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def execute_ruby(code)
|
171
|
+
Timeout.timeout(@timeout) do
|
172
|
+
# Создаем временный файл
|
173
|
+
Tempfile.create(['code', '.rb']) do |file|
|
174
|
+
file.write(code)
|
175
|
+
file.flush
|
176
|
+
|
177
|
+
# Выполняем код в отдельном процессе
|
178
|
+
result = `ruby #{file.path} 2>&1`
|
179
|
+
|
180
|
+
if $?.success?
|
181
|
+
result.strip
|
182
|
+
else
|
183
|
+
raise "Ruby execution failed: #{result}"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def execute_python(code)
|
190
|
+
Timeout.timeout(@timeout) do
|
191
|
+
Tempfile.create(['code', '.py']) do |file|
|
192
|
+
file.write(code)
|
193
|
+
file.flush
|
194
|
+
|
195
|
+
result = `python3 #{file.path} 2>&1`
|
196
|
+
|
197
|
+
if $?.success?
|
198
|
+
result.strip
|
199
|
+
else
|
200
|
+
raise "Python execution failed: #{result}"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def execute_javascript(code)
|
207
|
+
Timeout.timeout(@timeout) do
|
208
|
+
Tempfile.create(['code', '.js']) do |file|
|
209
|
+
file.write(code)
|
210
|
+
file.flush
|
211
|
+
|
212
|
+
# Пробуем node.js
|
213
|
+
result = `node #{file.path} 2>&1`
|
214
|
+
|
215
|
+
if $?.success?
|
216
|
+
result.strip
|
217
|
+
else
|
218
|
+
raise "JavaScript execution failed: #{result}"
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
def format_execution_result(code, language, result)
|
225
|
+
"Code execution (#{language}):\n\n```#{language}\n#{code}\n```\n\nOutput:\n```\n#{result}\n```"
|
226
|
+
end
|
227
|
+
|
228
|
+
def required_parameters
|
229
|
+
['code']
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
module LLMChain
|
2
|
+
module Tools
|
3
|
+
class ToolManager
|
4
|
+
attr_reader :tools
|
5
|
+
|
6
|
+
def initialize(tools: [])
|
7
|
+
@tools = {}
|
8
|
+
tools.each { |tool| register_tool(tool) }
|
9
|
+
end
|
10
|
+
|
11
|
+
# Регистрирует новый инструмент
|
12
|
+
def register_tool(tool)
|
13
|
+
unless tool.is_a?(BaseTool)
|
14
|
+
raise ArgumentError, "Tool must inherit from BaseTool"
|
15
|
+
end
|
16
|
+
@tools[tool.name] = tool
|
17
|
+
end
|
18
|
+
|
19
|
+
# Удаляет инструмент
|
20
|
+
def unregister_tool(name)
|
21
|
+
@tools.delete(name.to_s)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Получает инструмент по имени
|
25
|
+
def get_tool(name)
|
26
|
+
@tools[name.to_s]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Возвращает список всех инструментов
|
30
|
+
def list_tools
|
31
|
+
@tools.values
|
32
|
+
end
|
33
|
+
|
34
|
+
# Получает схемы всех инструментов для LLM
|
35
|
+
def get_tools_schema
|
36
|
+
@tools.values.map(&:to_schema)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Находит подходящие инструменты для промпта
|
40
|
+
def find_matching_tools(prompt)
|
41
|
+
@tools.values.select { |tool| tool.match?(prompt) }
|
42
|
+
end
|
43
|
+
|
44
|
+
# Выполняет все подходящие инструменты
|
45
|
+
def execute_tools(prompt, context: {})
|
46
|
+
matching_tools = find_matching_tools(prompt)
|
47
|
+
|
48
|
+
results = {}
|
49
|
+
matching_tools.each do |tool|
|
50
|
+
begin
|
51
|
+
result = tool.call(prompt, context: context)
|
52
|
+
results[tool.name] = {
|
53
|
+
success: true,
|
54
|
+
result: result,
|
55
|
+
formatted: tool.format_result(result)
|
56
|
+
}
|
57
|
+
rescue => e
|
58
|
+
results[tool.name] = {
|
59
|
+
success: false,
|
60
|
+
error: e.message,
|
61
|
+
formatted: "Error in #{tool.name}: #{e.message}"
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
results
|
67
|
+
end
|
68
|
+
|
69
|
+
# Выполняет конкретный инструмент по имени
|
70
|
+
def execute_tool(name, prompt, context: {})
|
71
|
+
tool = get_tool(name)
|
72
|
+
raise ArgumentError, "Tool '#{name}' not found" unless tool
|
73
|
+
|
74
|
+
begin
|
75
|
+
result = tool.call(prompt, context: context)
|
76
|
+
{
|
77
|
+
success: true,
|
78
|
+
result: result,
|
79
|
+
formatted: tool.format_result(result)
|
80
|
+
}
|
81
|
+
rescue => e
|
82
|
+
{
|
83
|
+
success: false,
|
84
|
+
error: e.message,
|
85
|
+
formatted: "Error in #{name}: #{e.message}"
|
86
|
+
}
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Создает стандартный набор инструментов
|
91
|
+
def self.create_default_toolset
|
92
|
+
tools = [
|
93
|
+
Calculator.new,
|
94
|
+
WebSearch.new,
|
95
|
+
CodeInterpreter.new
|
96
|
+
]
|
97
|
+
|
98
|
+
new(tools: tools)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Создает набор инструментов из конфигурации
|
102
|
+
def self.from_config(config)
|
103
|
+
tools = []
|
104
|
+
|
105
|
+
config.each do |tool_config|
|
106
|
+
tool_class = tool_config[:class] || tool_config['class']
|
107
|
+
tool_options = tool_config[:options] || tool_config['options'] || {}
|
108
|
+
|
109
|
+
case tool_class.to_s.downcase
|
110
|
+
when 'calculator'
|
111
|
+
tools << Calculator.new
|
112
|
+
when 'web_search', 'websearch'
|
113
|
+
tools << WebSearch.new(**tool_options)
|
114
|
+
when 'code_interpreter', 'codeinterpreter'
|
115
|
+
tools << CodeInterpreter.new(**tool_options)
|
116
|
+
else
|
117
|
+
raise ArgumentError, "Unknown tool class: #{tool_class}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
new(tools: tools)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Форматирует результаты выполнения для включения в промпт
|
125
|
+
def format_tool_results(results)
|
126
|
+
return "" if results.empty?
|
127
|
+
|
128
|
+
formatted_results = results.map do |tool_name, result|
|
129
|
+
"#{tool_name}: #{result[:formatted]}"
|
130
|
+
end
|
131
|
+
|
132
|
+
"Tool Results:\n#{formatted_results.join("\n\n")}"
|
133
|
+
end
|
134
|
+
|
135
|
+
# Получает краткое описание доступных инструментов
|
136
|
+
def tools_description
|
137
|
+
descriptions = @tools.values.map do |tool|
|
138
|
+
"- #{tool.name}: #{tool.description}"
|
139
|
+
end
|
140
|
+
|
141
|
+
"Available tools:\n#{descriptions.join("\n")}"
|
142
|
+
end
|
143
|
+
|
144
|
+
# Проверяет, содержит ли промпт запрос на использование инструментов
|
145
|
+
def needs_tools?(prompt)
|
146
|
+
# Проверяем явные запросы на использование инструментов
|
147
|
+
return true if prompt.match?(/\b(use tool|call tool|execute|calculate|search|run code)\b/i)
|
148
|
+
|
149
|
+
# Проверяем, есть ли подходящие инструменты
|
150
|
+
find_matching_tools(prompt).any?
|
151
|
+
end
|
152
|
+
|
153
|
+
# Автоматически решает, какие инструменты использовать
|
154
|
+
def auto_execute(prompt, context: {})
|
155
|
+
return {} unless needs_tools?(prompt)
|
156
|
+
|
157
|
+
# Ограничиваем количество одновременно выполняемых инструментов
|
158
|
+
matching_tools = find_matching_tools(prompt)
|
159
|
+
selected_tools = select_best_tools(matching_tools, prompt)
|
160
|
+
|
161
|
+
results = {}
|
162
|
+
selected_tools.each do |tool|
|
163
|
+
begin
|
164
|
+
result = tool.call(prompt, context: context)
|
165
|
+
results[tool.name] = {
|
166
|
+
success: true,
|
167
|
+
result: result,
|
168
|
+
formatted: tool.format_result(result)
|
169
|
+
}
|
170
|
+
rescue => e
|
171
|
+
results[tool.name] = {
|
172
|
+
success: false,
|
173
|
+
error: e.message,
|
174
|
+
formatted: "Error in #{tool.name}: #{e.message}"
|
175
|
+
}
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
results
|
180
|
+
end
|
181
|
+
|
182
|
+
private
|
183
|
+
|
184
|
+
# Выбирает лучшие инструменты для выполнения (ограничение по количеству)
|
185
|
+
def select_best_tools(tools, prompt, limit: 3)
|
186
|
+
# Простая логика приоритизации
|
187
|
+
prioritized = tools.sort_by do |tool|
|
188
|
+
case tool.name
|
189
|
+
when 'calculator'
|
190
|
+
prompt.include?('calculate') || prompt.match?(/\d+\s*[+\-*\/]\s*\d+/) ? 0 : 2
|
191
|
+
when 'web_search'
|
192
|
+
prompt.include?('search') || prompt.match?(/\b(what|who|where|when)\b/i) ? 0 : 2
|
193
|
+
when 'code_interpreter'
|
194
|
+
prompt.include?('```') || prompt.include?('code') ? 0 : 2
|
195
|
+
else
|
196
|
+
1
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
prioritized.first(limit)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
@@ -0,0 +1,255 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
|
5
|
+
module LLMChain
|
6
|
+
module Tools
|
7
|
+
class WebSearch < BaseTool
|
8
|
+
KEYWORDS = %w[
|
9
|
+
search find lookup google bing
|
10
|
+
what is who is where is when is
|
11
|
+
latest news current information
|
12
|
+
weather forecast stock price
|
13
|
+
definition meaning wikipedia
|
14
|
+
].freeze
|
15
|
+
|
16
|
+
def initialize(api_key: nil, search_engine: :duckduckgo)
|
17
|
+
@api_key = api_key || ENV['SEARCH_API_KEY']
|
18
|
+
@search_engine = search_engine
|
19
|
+
|
20
|
+
super(
|
21
|
+
name: "web_search",
|
22
|
+
description: "Searches the internet for current information",
|
23
|
+
parameters: {
|
24
|
+
query: {
|
25
|
+
type: "string",
|
26
|
+
description: "Search query to find information about"
|
27
|
+
},
|
28
|
+
num_results: {
|
29
|
+
type: "integer",
|
30
|
+
description: "Number of results to return (default: 5)"
|
31
|
+
}
|
32
|
+
}
|
33
|
+
)
|
34
|
+
end
|
35
|
+
|
36
|
+
def match?(prompt)
|
37
|
+
contains_keywords?(prompt, KEYWORDS) ||
|
38
|
+
contains_question_pattern?(prompt) ||
|
39
|
+
contains_current_info_request?(prompt)
|
40
|
+
end
|
41
|
+
|
42
|
+
def call(prompt, context: {})
|
43
|
+
query = extract_query(prompt)
|
44
|
+
return "No search query found" if query.empty?
|
45
|
+
|
46
|
+
num_results = extract_num_results(prompt)
|
47
|
+
|
48
|
+
begin
|
49
|
+
results = perform_search(query, num_results)
|
50
|
+
format_search_results(query, results)
|
51
|
+
rescue => e
|
52
|
+
{
|
53
|
+
query: query,
|
54
|
+
error: e.message,
|
55
|
+
formatted: "Error searching for '#{query}': #{e.message}"
|
56
|
+
}
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def extract_parameters(prompt)
|
61
|
+
{
|
62
|
+
query: extract_query(prompt),
|
63
|
+
num_results: extract_num_results(prompt)
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def contains_question_pattern?(prompt)
|
70
|
+
prompt.match?(/\b(what|who|where|when|how|why|which)\b/i)
|
71
|
+
end
|
72
|
+
|
73
|
+
def contains_current_info_request?(prompt)
|
74
|
+
prompt.match?(/\b(latest|current|recent|today|now|2024|2023)\b/i)
|
75
|
+
end
|
76
|
+
|
77
|
+
def extract_query(prompt)
|
78
|
+
# Удаляем команды поиска и оставляем суть запроса
|
79
|
+
query = prompt.gsub(/\b(search for|find|lookup|google|what is|who is|where is|when is)\b/i, '')
|
80
|
+
.gsub(/\b(please|can you|could you|would you)\b/i, '')
|
81
|
+
.strip
|
82
|
+
|
83
|
+
# Если запрос слишком длинный, берем первые слова
|
84
|
+
words = query.split
|
85
|
+
if words.length > 10
|
86
|
+
words.first(10).join(' ')
|
87
|
+
else
|
88
|
+
query
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def extract_num_results(prompt)
|
93
|
+
# Ищем числа в контексте результатов
|
94
|
+
match = prompt.match(/(\d+)\s*(results?|items?|links?)/i)
|
95
|
+
return match[1].to_i if match && match[1].to_i.between?(1, 20)
|
96
|
+
|
97
|
+
5 # default
|
98
|
+
end
|
99
|
+
|
100
|
+
def perform_search(query, num_results)
|
101
|
+
case @search_engine
|
102
|
+
when :duckduckgo
|
103
|
+
search_duckduckgo(query, num_results)
|
104
|
+
when :google
|
105
|
+
search_google(query, num_results)
|
106
|
+
when :bing
|
107
|
+
search_bing(query, num_results)
|
108
|
+
else
|
109
|
+
raise "Unsupported search engine: #{@search_engine}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def search_duckduckgo(query, num_results)
|
114
|
+
# DuckDuckGo Instant Answer API (бесплатный)
|
115
|
+
uri = URI("https://api.duckduckgo.com/")
|
116
|
+
params = {
|
117
|
+
q: query,
|
118
|
+
format: 'json',
|
119
|
+
no_html: '1',
|
120
|
+
skip_disambig: '1'
|
121
|
+
}
|
122
|
+
uri.query = URI.encode_www_form(params)
|
123
|
+
|
124
|
+
response = Net::HTTP.get_response(uri)
|
125
|
+
raise "DuckDuckGo API error: #{response.code}" unless response.code == '200'
|
126
|
+
|
127
|
+
data = JSON.parse(response.body)
|
128
|
+
|
129
|
+
results = []
|
130
|
+
|
131
|
+
# Основной ответ
|
132
|
+
if data['AbstractText'] && !data['AbstractText'].empty?
|
133
|
+
results << {
|
134
|
+
title: data['AbstractSource'] || 'DuckDuckGo',
|
135
|
+
url: data['AbstractURL'] || '',
|
136
|
+
snippet: data['AbstractText']
|
137
|
+
}
|
138
|
+
end
|
139
|
+
|
140
|
+
# Связанные темы
|
141
|
+
if data['RelatedTopics']
|
142
|
+
data['RelatedTopics'].first(num_results - results.length).each do |topic|
|
143
|
+
next unless topic['Text']
|
144
|
+
results << {
|
145
|
+
title: topic['Text'].split(' - ').first || 'Related',
|
146
|
+
url: topic['FirstURL'] || '',
|
147
|
+
snippet: topic['Text']
|
148
|
+
}
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Если результатов мало, добавляем информацию из Infobox
|
153
|
+
if results.length < num_results / 2 && data['Infobox']
|
154
|
+
infobox_text = data['Infobox']['content']&.map { |item|
|
155
|
+
"#{item['label']}: #{item['value']}"
|
156
|
+
}&.join('; ')
|
157
|
+
|
158
|
+
if infobox_text
|
159
|
+
results << {
|
160
|
+
title: 'Information',
|
161
|
+
url: data['AbstractURL'] || '',
|
162
|
+
snippet: infobox_text
|
163
|
+
}
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
results.first(num_results)
|
168
|
+
end
|
169
|
+
|
170
|
+
def search_google(query, num_results)
|
171
|
+
# Google Custom Search API (требует API ключ)
|
172
|
+
raise "Google API key required" unless @api_key
|
173
|
+
|
174
|
+
uri = URI("https://www.googleapis.com/customsearch/v1")
|
175
|
+
params = {
|
176
|
+
key: @api_key,
|
177
|
+
cx: ENV['GOOGLE_SEARCH_ENGINE_ID'] || raise("GOOGLE_SEARCH_ENGINE_ID required"),
|
178
|
+
q: query,
|
179
|
+
num: [num_results, 10].min
|
180
|
+
}
|
181
|
+
uri.query = URI.encode_www_form(params)
|
182
|
+
|
183
|
+
response = Net::HTTP.get_response(uri)
|
184
|
+
raise "Google API error: #{response.code}" unless response.code == '200'
|
185
|
+
|
186
|
+
data = JSON.parse(response.body)
|
187
|
+
|
188
|
+
(data['items'] || []).map do |item|
|
189
|
+
{
|
190
|
+
title: item['title'],
|
191
|
+
url: item['link'],
|
192
|
+
snippet: item['snippet']
|
193
|
+
}
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def search_bing(query, num_results)
|
198
|
+
# Bing Web Search API (требует API ключ)
|
199
|
+
raise "Bing API key required" unless @api_key
|
200
|
+
|
201
|
+
uri = URI("https://api.bing.microsoft.com/v7.0/search")
|
202
|
+
params = {
|
203
|
+
q: query,
|
204
|
+
count: [num_results, 20].min,
|
205
|
+
responseFilter: 'Webpages'
|
206
|
+
}
|
207
|
+
uri.query = URI.encode_www_form(params)
|
208
|
+
|
209
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
210
|
+
http.use_ssl = true
|
211
|
+
|
212
|
+
request = Net::HTTP::Get.new(uri)
|
213
|
+
request['Ocp-Apim-Subscription-Key'] = @api_key
|
214
|
+
|
215
|
+
response = http.request(request)
|
216
|
+
raise "Bing API error: #{response.code}" unless response.code == '200'
|
217
|
+
|
218
|
+
data = JSON.parse(response.body)
|
219
|
+
|
220
|
+
(data.dig('webPages', 'value') || []).map do |item|
|
221
|
+
{
|
222
|
+
title: item['name'],
|
223
|
+
url: item['url'],
|
224
|
+
snippet: item['snippet']
|
225
|
+
}
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
def format_search_results(query, results)
|
230
|
+
if results.empty?
|
231
|
+
return {
|
232
|
+
query: query,
|
233
|
+
results: [],
|
234
|
+
formatted: "No results found for '#{query}'"
|
235
|
+
}
|
236
|
+
end
|
237
|
+
|
238
|
+
formatted_results = results.map.with_index(1) do |result, index|
|
239
|
+
"#{index}. #{result[:title]}\n #{result[:snippet]}\n #{result[:url]}"
|
240
|
+
end.join("\n\n")
|
241
|
+
|
242
|
+
{
|
243
|
+
query: query,
|
244
|
+
results: results,
|
245
|
+
count: results.length,
|
246
|
+
formatted: "Search results for '#{query}':\n\n#{formatted_results}"
|
247
|
+
}
|
248
|
+
end
|
249
|
+
|
250
|
+
def required_parameters
|
251
|
+
['query']
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
data/lib/llm_chain/version.rb
CHANGED
data/lib/llm_chain.rb
CHANGED
@@ -24,4 +24,9 @@ require "llm_chain/memory/redis"
|
|
24
24
|
require "llm_chain/embeddings/clients/local/ollama_client"
|
25
25
|
require "llm_chain/embeddings/clients/local/weaviate_vector_store"
|
26
26
|
require "llm_chain/embeddings/clients/local/weaviate_retriever"
|
27
|
+
require "llm_chain/tools/base_tool"
|
28
|
+
require "llm_chain/tools/calculator"
|
29
|
+
require "llm_chain/tools/web_search"
|
30
|
+
require "llm_chain/tools/code_interpreter"
|
31
|
+
require "llm_chain/tools/tool_manager"
|
27
32
|
require "llm_chain/chain"
|