llm_chain 0.5.5 → 0.6.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.
@@ -5,11 +5,9 @@ require_relative '../lib/llm_chain'
5
5
  puts "šŸ› ļø LLMChain Tools Demo"
6
6
  puts "=" * 40
7
7
 
8
- # 1. Individual Tool Usage
9
8
  puts "\n1. 🧮 Calculator Tool"
10
9
  calculator = LLMChain::Tools::Calculator.new
11
10
 
12
- # Test mathematical expressions
13
11
  examples = [
14
12
  "15 * 7 + 3",
15
13
  "sqrt(144)",
@@ -21,7 +19,6 @@ examples.each do |expr|
21
19
  puts "šŸ“Š #{result[:formatted]}"
22
20
  end
23
21
 
24
- # 2. Web Search Tool
25
22
  puts "\n2. šŸ” Web Search Tool"
26
23
  search = LLMChain::Tools::WebSearch.new
27
24
 
@@ -45,15 +42,12 @@ search_queries.each do |query|
45
42
  end
46
43
  end
47
44
 
48
- # 3. Code Interpreter Tool
49
45
  puts "\n3. šŸ’» Code Interpreter Tool"
50
46
  interpreter = LLMChain::Tools::CodeInterpreter.new
51
47
 
52
- # Test Ruby code execution
53
48
  ruby_examples = [
54
49
  <<~RUBY,
55
50
  ```ruby
56
- # Simple Fibonacci calculation
57
51
  a, b = 0, 1
58
52
  result = [a, b]
59
53
  8.times do
@@ -69,7 +63,6 @@ ruby_examples = [
69
63
 
70
64
  <<~RUBY
71
65
  ```ruby
72
- # Data analysis example
73
66
  numbers = [23, 45, 67, 89, 12, 34, 56, 78]
74
67
 
75
68
  puts "Dataset: \#{numbers}"
@@ -97,159 +90,4 @@ ruby_examples.each_with_index do |code, i|
97
90
  rescue => e
98
91
  puts "āŒ Execution error: #{e.message}"
99
92
  end
100
- end
101
-
102
- # 4. Tool Manager Usage
103
- puts "\n4. šŸŽÆ Tool Manager"
104
- tool_manager = LLMChain::Tools::ToolManagerFactory.create_default_toolset
105
-
106
- puts "Registered tools: #{tool_manager.list_tools.map(&:name).join(', ')}"
107
-
108
- # Test tool matching
109
- test_prompts = [
110
- "What is 25 squared?",
111
- "Find information about Ruby gems",
112
- "Run this code: puts 'Hello World'"
113
- ]
114
-
115
- test_prompts.each do |prompt|
116
- puts "\nšŸŽÆ Testing: \"#{prompt}\""
117
- matched_tools = tool_manager.list_tools.select { |tool| tool.match?(prompt) }
118
-
119
- if matched_tools.any?
120
- puts " Matched tools: #{matched_tools.map(&:name).join(', ')}"
121
-
122
- # Execute with first matched tool
123
- tool = matched_tools.first
124
- result = tool.call(prompt)
125
- puts " Result: #{result[:formatted] || result.inspect}"
126
- else
127
- puts " No tools matched"
128
- end
129
- end
130
-
131
- # 5. LLM Chain with Tools
132
- puts "\n5. šŸ¤– LLM Chain with Tools"
133
-
134
- begin
135
- # Create chain with tools (but without retriever for local testing)
136
- chain = LLMChain::Chain.new(
137
- model: "qwen3:1.7b",
138
- tools: tool_manager,
139
- retriever: false # Disable RAG for this example
140
- )
141
-
142
- # Test queries that should trigger tools
143
- test_queries = [
144
- "Calculate the area of a circle with radius 5 (use pi = 3.14159)",
145
- "What's the latest news about Ruby programming?",
146
- "Execute this Ruby code: puts (1..100).select(&:even?).sum"
147
- ]
148
-
149
- test_queries.each_with_index do |query, i|
150
- puts "\nšŸ¤– Query #{i+1}: #{query}"
151
- puts "šŸ”„ Processing..."
152
-
153
- response = chain.ask(query)
154
- puts "šŸ“ Response: #{response}"
155
- puts "-" * 40
156
- end
157
-
158
- rescue => e
159
- puts "āŒ Error with LLM Chain: #{e.message}"
160
- puts "šŸ’” Make sure Ollama is running with qwen3:1.7b model"
161
- end
162
-
163
- # 6. Custom Tool Example
164
- puts "\n6. šŸ”§ Custom Tool Example"
165
-
166
- # Create a simple DateTime tool
167
- class DateTimeTool < LLMChain::Tools::BaseTool
168
- def initialize
169
- super(
170
- name: "datetime",
171
- description: "Gets current date and time information",
172
- parameters: {
173
- format: {
174
- type: "string",
175
- description: "Date format (optional)"
176
- }
177
- }
178
- )
179
- end
180
-
181
- def match?(prompt)
182
- contains_keywords?(prompt, ['time', 'date', 'now', 'current', 'today'])
183
- end
184
-
185
- def call(prompt, context: {})
186
- now = Time.now
187
-
188
- # Try to detect desired format from prompt
189
- format = if prompt.match?(/iso|standard/i)
190
- now.iso8601
191
- elsif prompt.match?(/human|readable/i)
192
- now.strftime("%B %d, %Y at %I:%M %p")
193
- else
194
- now.to_s
195
- end
196
-
197
- {
198
- timestamp: now.to_i,
199
- formatted_time: format,
200
- timezone: now.zone,
201
- formatted: "Current time: #{format} (#{now.zone})"
202
- }
203
- end
204
- end
205
-
206
- # Test custom tool
207
- datetime_tool = DateTimeTool.new
208
- time_queries = [
209
- "What time is it now?",
210
- "Give me current date in human readable format",
211
- "Show me the current time in ISO format"
212
- ]
213
-
214
- time_queries.each do |query|
215
- puts "\nšŸ• Query: #{query}"
216
- if datetime_tool.match?(query)
217
- result = datetime_tool.call(query)
218
- puts " #{result[:formatted]}"
219
- else
220
- puts " Query didn't match datetime tool"
221
- end
222
- end
223
-
224
- # 7. Configuration-based Tool Setup
225
- puts "\n7. āš™ļø Configuration-based Tools"
226
-
227
- tools_config = [
228
- {
229
- class: 'calculator'
230
- },
231
- {
232
- class: 'web_search',
233
- options: {
234
- search_engine: :duckduckgo
235
- }
236
- },
237
- {
238
- class: 'code_interpreter',
239
- options: {
240
- timeout: 30,
241
- allowed_languages: ['ruby', 'python']
242
- }
243
- }
244
- ]
245
-
246
- config_tool_manager = LLMChain::Tools::ToolManagerFactory.from_config(tools_config)
247
- puts "Tools from config: #{config_tool_manager.list_tools.map(&:name).join(', ')}"
248
-
249
- # Test configuration-based setup
250
- config_result = config_tool_manager.execute_tool('calculator', 'What is 99 * 99?')
251
- puts "Config test result: #{config_result[:formatted]}" if config_result
252
-
253
- puts "\n" + "=" * 40
254
- puts "✨ Tools demo completed!"
255
- puts "\nTry running the chain with: ruby -I lib examples/quick_demo.rb"
93
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../interfaces/agent'
4
+
5
+ module LLMChain
6
+ module Agents
7
+ # Factory for creating different types of agents
8
+ #
9
+ # Provides a centralized way to instantiate agents with proper configuration.
10
+ # Supports dependency injection and easy extension for new agent types.
11
+ #
12
+ # @example Basic usage
13
+ # agent = AgentFactory.create(type: :react, model: "qwen3:1.7b")
14
+ # result = agent.run("Analyze this data")
15
+ #
16
+ # @example With custom tools and memory
17
+ # agent = AgentFactory.create(
18
+ # type: :react,
19
+ # model: "gpt-4",
20
+ # tools: custom_tool_manager,
21
+ # memory: redis_memory
22
+ # )
23
+ #
24
+ # @example Registering custom agent
25
+ # class MyCustomAgent < LLMChain::Interfaces::Agent
26
+ # # implementation
27
+ # end
28
+ #
29
+ # AgentFactory.register(:my_custom, MyCustomAgent)
30
+ # agent = AgentFactory.create(type: :my_custom)
31
+ class AgentFactory
32
+ @registry = {}
33
+ @descriptions = {}
34
+
35
+ # Register a custom agent class
36
+ # @param type [Symbol] agent type identifier
37
+ # @param agent_class [Class] agent class that implements LLMChain::Interfaces::Agent
38
+ # @param description [String] human-readable description of the agent
39
+ # @return [void]
40
+ def self.register(type, agent_class, description: nil)
41
+ unless agent_class.ancestors.include?(LLMChain::Interfaces::Agent)
42
+ raise ArgumentError, "Agent class must implement LLMChain::Interfaces::Agent"
43
+ end
44
+
45
+ @registry[type.to_sym] = agent_class
46
+ @descriptions[type.to_sym] = description || "Custom agent: #{agent_class.name}"
47
+ end
48
+
49
+ # Create an agent instance by type
50
+ # @param type [Symbol] the type of agent to create
51
+ # @param model [String] LLM model identifier
52
+ # @param tools [LLMChain::Interfaces::ToolManager] tool manager
53
+ # @param memory [LLMChain::Interfaces::Memory] memory backend
54
+ # @param max_iterations [Integer] maximum reasoning iterations (for ReAct)
55
+ # @param client_options [Hash] additional client options
56
+ # @return [LLMChain::Interfaces::Agent] agent instance
57
+ # @raise [ArgumentError] when agent type is unknown
58
+ def self.create(
59
+ type:,
60
+ model: nil,
61
+ tools: nil,
62
+ memory: nil,
63
+ max_iterations: 5,
64
+ **client_options
65
+ )
66
+ type_sym = type.to_sym
67
+
68
+ # Get registered agent class
69
+ agent_class = @registry[type_sym]
70
+ unless agent_class
71
+ raise ArgumentError, "Unknown agent type: #{type}. Supported types: #{supported_types.join(', ')}"
72
+ end
73
+
74
+ # Create agent instance with standard parameters
75
+ agent_class.new(
76
+ model: model || LLMChain.configuration.default_model,
77
+ tools: tools || LLMChain::Tools::ToolManagerFactory.create_default_toolset,
78
+ memory: memory || LLMChain::Memory::Array.new,
79
+ max_iterations: max_iterations,
80
+ **client_options
81
+ )
82
+ end
83
+
84
+ # Get list of supported agent types
85
+ # @return [Array<Symbol>] supported agent types
86
+ def self.supported_types
87
+ @registry.keys
88
+ end
89
+
90
+ # Check if agent type is supported
91
+ # @param type [Symbol] agent type to check
92
+ # @return [Boolean] whether the type is supported
93
+ def self.supported?(type)
94
+ supported_types.include?(type.to_sym)
95
+ end
96
+
97
+ # Get description of available agent types
98
+ # @return [Hash<Symbol, String>] agent type descriptions
99
+ def self.agent_descriptions
100
+ @descriptions
101
+ end
102
+
103
+ # Unregister a custom agent
104
+ # @param type [Symbol] agent type to unregister
105
+ # @return [void]
106
+ def self.unregister(type)
107
+ type_sym = type.to_sym
108
+ @registry.delete(type_sym)
109
+ @descriptions.delete(type_sym)
110
+ end
111
+
112
+ # Get registered agent class
113
+ # @param type [Symbol] agent type
114
+ # @return [Class, nil] agent class or nil if not registered
115
+ def self.get_registered_agent(type)
116
+ @registry[type.to_sym]
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../interfaces/agent'
4
+ require_relative 'agent_factory'
5
+ require_relative 'planner_agent'
6
+ require_relative 'react_agent'
7
+
8
+ module LLMChain
9
+ module Agents
10
+ # Composite agent that combines planning and execution capabilities.
11
+ #
12
+ # This agent uses a PlannerAgent to decompose complex tasks into atomic steps,
13
+ # then uses a ReActAgent to execute each step. This provides better handling
14
+ # of multi-step tasks compared to using ReActAgent alone.
15
+ #
16
+ # @example Basic usage
17
+ # agent = LLMChain::Agents::CompositeAgent.new(
18
+ # model: "qwen3:1.7b",
19
+ # tools: tool_manager
20
+ # )
21
+ # result = agent.run("Find the president of the US and the capital of France")
22
+ class CompositeAgent < LLMChain::Interfaces::Agent
23
+ attr_reader :model, :tools, :memory, :planner, :executor
24
+
25
+ # Initialize the composite agent with planning and execution capabilities.
26
+ # @param model [String] LLM model identifier
27
+ # @param tools [LLMChain::Interfaces::ToolManager] tool manager
28
+ # @param memory [LLMChain::Interfaces::Memory] memory backend
29
+ # @param max_iterations [Integer] maximum reasoning iterations for executor
30
+ # @param client_options [Hash] additional client options
31
+ def initialize(model:, tools:, memory: nil, max_iterations: 3, **client_options)
32
+ @model = model
33
+ @tools = tools
34
+ @memory = memory || LLMChain::Memory::Array.new
35
+ @max_iterations = max_iterations
36
+
37
+ # Create planner and executor agents through factory
38
+ @planner = AgentFactory.create(
39
+ type: :planner,
40
+ model: @model,
41
+ **client_options
42
+ )
43
+ @executor = AgentFactory.create(
44
+ type: :react,
45
+ model: @model,
46
+ tools: @tools,
47
+ memory: @memory,
48
+ max_iterations: @max_iterations,
49
+ **client_options
50
+ )
51
+ end
52
+
53
+ # Execute a task using planning and execution methodology.
54
+ # @param task [String] the task to accomplish
55
+ # @param stream [Boolean] whether to stream reasoning steps
56
+ # @yield [Hash] reasoning step information
57
+ # @return [Hash] final result with reasoning trace
58
+ def run(task, stream: false, &block)
59
+ # Step 1: Determine if task needs planning
60
+ use_planner = should_use_planner?(task)
61
+
62
+ if use_planner
63
+ # Use planning approach for complex tasks
64
+ run_with_planning(task, stream: stream, &block)
65
+ else
66
+ # Use direct execution for simple tasks
67
+ execute_directly(task, stream: stream, &block)
68
+ end
69
+ end
70
+
71
+ # Check if this agent can handle the given task.
72
+ # @param task [String] task description
73
+ # @return [Boolean] whether this agent can handle the task
74
+ def can_handle?(task)
75
+ # Composite agent can handle any task that the planner or executor can handle
76
+ @planner.can_handle?(task) || @executor.can_handle?(task)
77
+ end
78
+
79
+ # Get description of agent capabilities.
80
+ # @return [String] agent description
81
+ def description
82
+ "Composite agent with intelligent planning and execution capabilities for complex multi-step tasks"
83
+ end
84
+
85
+ private
86
+
87
+ # Determine if task should use planning approach
88
+ # @param task [String] the task to analyze
89
+ # @return [Boolean] whether to use planning
90
+ def should_use_planner?(task)
91
+ # Simple tasks that don't need planning
92
+ simple_patterns = [
93
+ /\bcalculate\s+\d+\s*[\+\-\*\/]\s*\d+\b/i,
94
+ /\bwhat\s+is\s+\d+\s*[\+\-\*\/]\s*\d+\b/i,
95
+ /\b\d+\s*[\+\-\*\/]\s*\d+\b/,
96
+ /\bwhat\s+time\s+is\s+it\b/i,
97
+ /\bcurrent\s+time\b/i,
98
+ /\bwhat\s+year\s+is\s+it\b/i,
99
+ /\bcurrent\s+date\b/i,
100
+ /\bwhat\s+time\b/i,
101
+ /\bcurrent\s+date\b/i
102
+ ]
103
+
104
+ # Complex patterns that need planning
105
+ complex_patterns = [
106
+ /\band\b/i,
107
+ /\bthen\b/i,
108
+ /\bnext\b/i,
109
+ /\bfind\b.*\band\b/i,
110
+ /\bsearch\b.*\band\b/i,
111
+ /\bget\b.*\band\b/i,
112
+ /\bcalculate\b.*\band\b/i,
113
+ /\bwhat\s+is\b.*\band\b/i
114
+ ]
115
+
116
+ # Check if task matches simple patterns
117
+ return false if simple_patterns.any? { |pattern| task.match?(pattern) }
118
+
119
+ # Check if task matches complex patterns
120
+ return true if complex_patterns.any? { |pattern| task.match?(pattern) }
121
+
122
+ # Default to planning for tasks longer than 50 characters
123
+ task.length > 50
124
+ end
125
+
126
+ # Run task with planning approach
127
+ # @param task [String] the task to accomplish
128
+ # @param stream [Boolean] whether to stream reasoning steps
129
+ # @yield [Hash] reasoning step information
130
+ # @return [Hash] final result with reasoning trace
131
+ def run_with_planning(task, stream: false, &block)
132
+ # Step 1: Plan - Decompose the task into atomic steps
133
+ planning_result = @planner.run(task, stream: stream, &block)
134
+ steps = planning_result[:steps] || [task]
135
+
136
+ # Step 2: Execute - Run each step with the executor
137
+ execution_results = []
138
+ validated_answers = []
139
+
140
+ steps.each_with_index do |step, index|
141
+ step_result = @executor.run(step, stream: stream, &block)
142
+ execution_results << step_result
143
+
144
+ # Validate and process the result
145
+ validated_answer = validate_and_process_result(step_result, step, index + 1)
146
+ validated_answers << validated_answer if validated_answer
147
+
148
+ # Yield step completion if streaming
149
+ if block_given? && stream
150
+ yield({
151
+ step: index + 1,
152
+ total_steps: steps.length,
153
+ current_step: step,
154
+ step_result: step_result,
155
+ validated_answer: validated_answer,
156
+ type: "step_completion"
157
+ })
158
+ end
159
+ end
160
+
161
+ # Step 3: Compile final result with smart aggregation
162
+ final_answer = smart_aggregate_results(validated_answers, task)
163
+
164
+ {
165
+ task: task,
166
+ final_answer: final_answer,
167
+ reasoning_trace: [
168
+ {
169
+ step: 1,
170
+ action: "plan",
171
+ action_input: task,
172
+ observation: "Decomposed into #{steps.length} steps: #{steps.join(', ')}"
173
+ },
174
+ *execution_results.flat_map { |result| result[:reasoning_trace] }
175
+ ],
176
+ iterations: execution_results.sum { |result| result[:iterations] || 0 },
177
+ success: validate_overall_success(execution_results, validated_answers, task),
178
+ planning_result: planning_result,
179
+ execution_results: execution_results,
180
+ validated_answers: validated_answers
181
+ }
182
+ end
183
+
184
+ # Execute task directly without planning
185
+ # @param task [String] the task to accomplish
186
+ # @param stream [Boolean] whether to stream reasoning steps
187
+ # @yield [Hash] reasoning step information
188
+ # @return [Hash] final result with reasoning trace
189
+ def execute_directly(task, stream: false, &block)
190
+ result = @executor.run(task, stream: stream, &block)
191
+
192
+ {
193
+ task: task,
194
+ final_answer: result[:final_answer],
195
+ reasoning_trace: result[:reasoning_trace],
196
+ iterations: result[:iterations],
197
+ success: result[:success],
198
+ planning_result: { steps: [task] },
199
+ execution_results: [result],
200
+ validated_answers: [],
201
+ approach: "direct"
202
+ }
203
+ end
204
+
205
+ # Validate and process execution result
206
+ # @param result [Hash] execution result
207
+ # @param step [String] step description
208
+ # @param step_number [Integer] step number
209
+ # @return [Hash, nil] validated and processed result
210
+ def validate_and_process_result(result, step, step_number)
211
+ return nil unless result[:success] && result[:final_answer]
212
+
213
+ answer = result[:final_answer]
214
+
215
+ # Check for error indicators
216
+ error_indicators = [
217
+ "unable to complete",
218
+ "insufficient data",
219
+ "error",
220
+ "failed",
221
+ "please provide",
222
+ "no results found"
223
+ ]
224
+
225
+ return nil if error_indicators.any? { |indicator| answer.downcase.include?(indicator) }
226
+
227
+ # Extract meaningful information based on step type
228
+ processed_answer = extract_meaningful_info(answer, step)
229
+
230
+ {
231
+ step_number: step_number,
232
+ step: step,
233
+ original_answer: answer,
234
+ processed_answer: processed_answer,
235
+ quality_score: calculate_quality_score(answer, step)
236
+ }
237
+ end
238
+
239
+ # Extract meaningful information from answer
240
+ # @param answer [String] original answer
241
+ # @param step [String] step description
242
+ # @return [String] processed answer
243
+ def extract_meaningful_info(answer, step)
244
+ # For mathematical calculations, extract the result
245
+ if step.match?(/\b(calculate|add|multiply|divide|subtract)\b/i)
246
+ # Look for numbers in the answer
247
+ numbers = answer.scan(/\d+/).map(&:to_i)
248
+ return numbers.last.to_s if numbers.any?
249
+ end
250
+
251
+ # For time/date queries, extract the formatted time
252
+ if step.match?(/\b(time|date|year)\b/i) && answer.include?("formatted")
253
+ # Extract the formatted time from JSON
254
+ if match = answer.match(/"formatted":\s*"([^"]+)"/)
255
+ return match[1]
256
+ end
257
+ end
258
+
259
+ # For web search results, extract the most relevant snippet
260
+ if answer.include?("Search results") && answer.include?("snippet")
261
+ # Extract first meaningful snippet
262
+ if match = answer.match(/"snippet":\s*"([^"]+)"/)
263
+ return match[1].gsub(/\.\.\./, '').strip
264
+ end
265
+ end
266
+
267
+ # Default: return first 100 characters
268
+ answer.length > 100 ? answer[0..100] + "..." : answer
269
+ end
270
+
271
+ # Calculate quality score for answer
272
+ # @param answer [String] answer to score
273
+ # @param step [String] step description
274
+ # @return [Integer] quality score (0-10)
275
+ def calculate_quality_score(answer, step)
276
+ score = 5 # Base score
277
+
278
+ # Penalize error indicators
279
+ error_indicators = ["unable to complete", "insufficient data", "error", "failed"]
280
+ score -= 3 if error_indicators.any? { |indicator| answer.downcase.include?(indicator) }
281
+
282
+ # Reward meaningful content
283
+ score += 2 if answer.length > 20
284
+ score += 1 if answer.match?(/\d+/)
285
+ score += 1 if answer.match?(/[A-Za-z]+/)
286
+
287
+ # Reward specific content types
288
+ score += 2 if step.match?(/\b(calculate|add|multiply)\b/i) && answer.match?(/\d+/)
289
+ score += 2 if step.match?(/\b(time|date)\b/i) && answer.include?("formatted")
290
+ score += 2 if step.match?(/\b(search|find)\b/i) && answer.include?("results")
291
+
292
+ [score, 10].min # Cap at 10
293
+ end
294
+
295
+ # Smart aggregation of results
296
+ # @param validated_answers [Array] validated answers
297
+ # @param original_task [String] original task
298
+ # @return [String] aggregated result
299
+ def smart_aggregate_results(validated_answers, original_task)
300
+ return "Task could not be completed successfully." if validated_answers.empty?
301
+
302
+ # For single answer, return it directly
303
+ if validated_answers.length == 1
304
+ return validated_answers.first[:processed_answer]
305
+ end
306
+
307
+ # For multiple answers, create a structured response
308
+ parts = []
309
+ validated_answers.each_with_index do |answer, index|
310
+ parts << "Part #{index + 1}: #{answer[:processed_answer]}"
311
+ end
312
+
313
+ # Add summary if it's a complex task
314
+ if original_task.match?(/\band\b/i)
315
+ parts << "\nSummary: All requested information has been gathered."
316
+ end
317
+
318
+ parts.join("\n\n")
319
+ end
320
+
321
+ # Validate overall success
322
+ # @param execution_results [Array] execution results
323
+ # @param validated_answers [Array] validated answers
324
+ # @param task [String] original task
325
+ # @return [Boolean] overall success
326
+ def validate_overall_success(execution_results, validated_answers, task)
327
+ # Check if we have any valid answers
328
+ return false if validated_answers.empty?
329
+
330
+ # For simple tasks, one good answer is enough
331
+ return true unless task.match?(/\band\b/i)
332
+
333
+ # For complex tasks, we need at least 50% of expected parts
334
+ expected_parts = task.scan(/\band\b/i).length + 1
335
+ actual_parts = validated_answers.length
336
+
337
+ actual_parts >= (expected_parts * 0.5).ceil
338
+ end
339
+ end
340
+ end
341
+ end