llm_chain 0.3.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,81 @@
1
+ module LLMChain
2
+ module Tools
3
+ class BaseTool
4
+ attr_reader :name, :description, :parameters
5
+
6
+ def initialize(name:, description:, parameters: {})
7
+ @name = name
8
+ @description = description
9
+ @parameters = parameters
10
+ end
11
+
12
+ # Проверяет, подходит ли инструмент для данного промпта
13
+ # @param prompt [String] Входной промпт от пользователя
14
+ # @return [Boolean] true если инструмент должен быть вызван
15
+ def match?(prompt)
16
+ raise NotImplementedError, "Subclasses must implement #match?"
17
+ end
18
+
19
+ # Выполняет инструмент
20
+ # @param prompt [String] Входной промпт от пользователя
21
+ # @param context [Hash] Дополнительный контекст
22
+ # @return [String, Hash] Результат выполнения инструмента
23
+ def call(prompt, context: {})
24
+ raise NotImplementedError, "Subclasses must implement #call"
25
+ end
26
+
27
+ # Возвращает JSON-схему для LLM
28
+ def to_schema
29
+ {
30
+ name: @name,
31
+ description: @description,
32
+ parameters: {
33
+ type: "object",
34
+ properties: @parameters,
35
+ required: required_parameters
36
+ }
37
+ }
38
+ end
39
+
40
+ # Извлекает параметры из промпта (для автоматического парсинга)
41
+ # @param prompt [String] Входной промпт
42
+ # @return [Hash] Извлеченные параметры
43
+ def extract_parameters(prompt)
44
+ {}
45
+ end
46
+
47
+ # Форматирует результат для включения в промпт
48
+ # @param result [Object] Результат выполнения инструмента
49
+ # @return [String] Форматированный результат
50
+ def format_result(result)
51
+ case result
52
+ when String then result
53
+ when Hash, Array then JSON.pretty_generate(result)
54
+ else result.to_s
55
+ end
56
+ end
57
+
58
+ protected
59
+
60
+ # Список обязательных параметров
61
+ def required_parameters
62
+ []
63
+ end
64
+
65
+ # Помощник для проверки ключевых слов в промпте
66
+ def contains_keywords?(prompt, keywords)
67
+ keywords.any? { |keyword| prompt.downcase.include?(keyword.downcase) }
68
+ end
69
+
70
+ # Помощник для извлечения числовых значений
71
+ def extract_numbers(text)
72
+ text.scan(/-?\d+\.?\d*/).map(&:to_f)
73
+ end
74
+
75
+ # Помощник для извлечения URL
76
+ def extract_urls(text)
77
+ text.scan(%r{https?://[^\s]+})
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,143 @@
1
+ require 'bigdecimal'
2
+
3
+ module LLMChain
4
+ module Tools
5
+ class Calculator < BaseTool
6
+ KEYWORDS = %w[
7
+ calculate compute math equation formula
8
+ add subtract multiply divide
9
+ sum difference product quotient
10
+ plus minus times divided
11
+ + - * / = equals
12
+ ].freeze
13
+
14
+ def initialize
15
+ super(
16
+ name: "calculator",
17
+ description: "Performs mathematical calculations and evaluates expressions",
18
+ parameters: {
19
+ expression: {
20
+ type: "string",
21
+ description: "Mathematical expression to evaluate (e.g., '2 + 2', '15 * 3.14', 'sqrt(16)')"
22
+ }
23
+ }
24
+ )
25
+ end
26
+
27
+ def match?(prompt)
28
+ contains_keywords?(prompt, KEYWORDS) ||
29
+ contains_math_pattern?(prompt)
30
+ end
31
+
32
+ def call(prompt, context: {})
33
+ expression = extract_expression(prompt)
34
+ return "No mathematical expression found" if expression.empty?
35
+
36
+ begin
37
+ result = evaluate_expression(expression)
38
+ {
39
+ expression: expression,
40
+ result: result,
41
+ formatted: "#{expression} = #{result}"
42
+ }
43
+ rescue => e
44
+ {
45
+ expression: expression,
46
+ error: e.message,
47
+ formatted: "Error calculating '#{expression}': #{e.message}"
48
+ }
49
+ end
50
+ end
51
+
52
+ def extract_parameters(prompt)
53
+ { expression: extract_expression(prompt) }
54
+ end
55
+
56
+ private
57
+
58
+ def contains_math_pattern?(prompt)
59
+ # Проверяем наличие математических операторов и чисел
60
+ prompt.match?(/\d+\s*[+\-*\/]\s*\d+/) ||
61
+ prompt.match?(/\b(sqrt|sin|cos|tan|log|ln|exp|abs|round|ceil|floor)\s*\(/i)
62
+ end
63
+
64
+ def extract_expression(prompt)
65
+ # Пробуем найти выражение в кавычках
66
+ quoted = prompt.match(/"([^"]+)"/) || prompt.match(/'([^']+)'/)
67
+ return quoted[1] if quoted
68
+
69
+ # Ищем выражение после ключевых слов
70
+ KEYWORDS.each do |keyword|
71
+ if prompt.downcase.include?(keyword)
72
+ escaped_keyword = Regexp.escape(keyword)
73
+ after_keyword = prompt.split(/#{escaped_keyword}/i, 2)[1]
74
+ if after_keyword
75
+ # Извлекаем математическое выражение
76
+ expr = after_keyword.strip.split(/[.!?]/).first
77
+ return clean_expression(expr) if expr
78
+ end
79
+ end
80
+ end
81
+
82
+ # Пробуем найти простое выражение в тексте
83
+ math_expr = prompt.match(/(\d+(?:\.\d+)?\s*[+\-*\/]\s*\d+(?:\.\d+)?(?:\s*[+\-*\/]\s*\d+(?:\.\d+)?)*)/)
84
+ return math_expr[1] if math_expr
85
+
86
+ # Ищем функции
87
+ func_expr = prompt.match(/\b(sqrt|sin|cos|tan|log|ln|exp|abs|round|ceil|floor)\s*\([^)]+\)/i)
88
+ return func_expr[0] if func_expr
89
+
90
+ ""
91
+ end
92
+
93
+ def clean_expression(expr)
94
+ # Удаляем лишние слова и оставляем только математическое выражение
95
+ expr.gsub(/\b(is|what|equals?|result|answer)\b/i, '')
96
+ .gsub(/[^\d+\-*\/().\s]/, '')
97
+ .strip
98
+ end
99
+
100
+ def evaluate_expression(expression)
101
+ # Заменяем математические функции на Ruby-методы
102
+ expr = expression.downcase
103
+ .gsub(/sqrt\s*\(([^)]+)\)/) { "Math.sqrt(#{$1})" }
104
+ .gsub(/sin\s*\(([^)]+)\)/) { "Math.sin(#{$1})" }
105
+ .gsub(/cos\s*\(([^)]+)\)/) { "Math.cos(#{$1})" }
106
+ .gsub(/tan\s*\(([^)]+)\)/) { "Math.tan(#{$1})" }
107
+ .gsub(/log\s*\(([^)]+)\)/) { "Math.log10(#{$1})" }
108
+ .gsub(/ln\s*\(([^)]+)\)/) { "Math.log(#{$1})" }
109
+ .gsub(/exp\s*\(([^)]+)\)/) { "Math.exp(#{$1})" }
110
+ .gsub(/abs\s*\(([^)]+)\)/) { "(#{$1}).abs" }
111
+ .gsub(/round\s*\(([^)]+)\)/) { "(#{$1}).round" }
112
+ .gsub(/ceil\s*\(([^)]+)\)/) { "(#{$1}).ceil" }
113
+ .gsub(/floor\s*\(([^)]+)\)/) { "(#{$1}).floor" }
114
+
115
+ # Безопасная оценка выражения
116
+ result = safe_eval(expr)
117
+
118
+ # Округляем результат до разумного количества знаков
119
+ if result.is_a?(Float)
120
+ result.round(10)
121
+ else
122
+ result
123
+ end
124
+ end
125
+
126
+ def safe_eval(expression)
127
+ # Проверяем, что выражение содержит только безопасные символы
128
+ unless expression.match?(/\A[\d+\-*\/().\s]+\z/) ||
129
+ expression.include?('Math.') ||
130
+ expression.match?(/\.(abs|round|ceil|floor)\b/)
131
+ raise "Unsafe expression: #{expression}"
132
+ end
133
+
134
+ # Оцениваем выражение
135
+ eval(expression)
136
+ end
137
+
138
+ def required_parameters
139
+ ['expression']
140
+ end
141
+ end
142
+ end
143
+ end
@@ -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