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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmChain
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
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"