desiru 0.1.0 → 0.1.1

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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +34 -0
  3. data/.rubocop.yml +7 -4
  4. data/.ruby-version +1 -0
  5. data/CLAUDE.md +4 -0
  6. data/Gemfile +21 -2
  7. data/Gemfile.lock +87 -12
  8. data/README.md +295 -2
  9. data/Rakefile +1 -0
  10. data/db/migrations/001_create_initial_tables.rb +96 -0
  11. data/db/migrations/002_create_job_results.rb +39 -0
  12. data/desiru.db +0 -0
  13. data/desiru.gemspec +2 -5
  14. data/docs/background_processing_roadmap.md +87 -0
  15. data/docs/job_scheduling.md +167 -0
  16. data/dspy-analysis-swarm.yml +60 -0
  17. data/dspy-feature-analysis.md +121 -0
  18. data/examples/README.md +69 -0
  19. data/examples/api_with_persistence.rb +122 -0
  20. data/examples/assertions_example.rb +232 -0
  21. data/examples/async_processing.rb +2 -0
  22. data/examples/few_shot_learning.rb +1 -2
  23. data/examples/graphql_api.rb +4 -2
  24. data/examples/graphql_integration.rb +3 -3
  25. data/examples/graphql_optimization_summary.md +143 -0
  26. data/examples/graphql_performance_benchmark.rb +247 -0
  27. data/examples/persistence_example.rb +102 -0
  28. data/examples/react_agent.rb +203 -0
  29. data/examples/rest_api.rb +173 -0
  30. data/examples/rest_api_advanced.rb +333 -0
  31. data/examples/scheduled_job_example.rb +116 -0
  32. data/examples/simple_qa.rb +1 -2
  33. data/examples/sinatra_api.rb +109 -0
  34. data/examples/typed_signatures.rb +1 -2
  35. data/graphql_optimization_summary.md +53 -0
  36. data/lib/desiru/api/grape_integration.rb +284 -0
  37. data/lib/desiru/api/persistence_middleware.rb +148 -0
  38. data/lib/desiru/api/sinatra_integration.rb +217 -0
  39. data/lib/desiru/api.rb +42 -0
  40. data/lib/desiru/assertions.rb +74 -0
  41. data/lib/desiru/async_status.rb +65 -0
  42. data/lib/desiru/cache.rb +1 -1
  43. data/lib/desiru/configuration.rb +2 -1
  44. data/lib/desiru/errors.rb +160 -0
  45. data/lib/desiru/field.rb +17 -14
  46. data/lib/desiru/graphql/batch_loader.rb +85 -0
  47. data/lib/desiru/graphql/data_loader.rb +242 -75
  48. data/lib/desiru/graphql/enum_builder.rb +75 -0
  49. data/lib/desiru/graphql/executor.rb +37 -4
  50. data/lib/desiru/graphql/schema_generator.rb +62 -158
  51. data/lib/desiru/graphql/type_builder.rb +138 -0
  52. data/lib/desiru/graphql/type_cache_warmer.rb +91 -0
  53. data/lib/desiru/jobs/async_predict.rb +1 -1
  54. data/lib/desiru/jobs/base.rb +67 -0
  55. data/lib/desiru/jobs/batch_processor.rb +6 -6
  56. data/lib/desiru/jobs/retriable.rb +119 -0
  57. data/lib/desiru/jobs/retry_strategies.rb +169 -0
  58. data/lib/desiru/jobs/scheduler.rb +219 -0
  59. data/lib/desiru/jobs/webhook_notifier.rb +242 -0
  60. data/lib/desiru/models/anthropic.rb +164 -0
  61. data/lib/desiru/models/base.rb +37 -3
  62. data/lib/desiru/models/open_ai.rb +151 -0
  63. data/lib/desiru/models/open_router.rb +161 -0
  64. data/lib/desiru/module.rb +59 -9
  65. data/lib/desiru/modules/chain_of_thought.rb +3 -3
  66. data/lib/desiru/modules/majority.rb +51 -0
  67. data/lib/desiru/modules/multi_chain_comparison.rb +204 -0
  68. data/lib/desiru/modules/predict.rb +8 -1
  69. data/lib/desiru/modules/program_of_thought.rb +139 -0
  70. data/lib/desiru/modules/react.rb +273 -0
  71. data/lib/desiru/modules/retrieve.rb +4 -2
  72. data/lib/desiru/optimizers/base.rb +2 -4
  73. data/lib/desiru/optimizers/bootstrap_few_shot.rb +2 -2
  74. data/lib/desiru/optimizers/copro.rb +268 -0
  75. data/lib/desiru/optimizers/knn_few_shot.rb +185 -0
  76. data/lib/desiru/persistence/database.rb +71 -0
  77. data/lib/desiru/persistence/models/api_request.rb +38 -0
  78. data/lib/desiru/persistence/models/job_result.rb +138 -0
  79. data/lib/desiru/persistence/models/module_execution.rb +37 -0
  80. data/lib/desiru/persistence/models/optimization_result.rb +28 -0
  81. data/lib/desiru/persistence/models/training_example.rb +25 -0
  82. data/lib/desiru/persistence/models.rb +11 -0
  83. data/lib/desiru/persistence/repositories/api_request_repository.rb +98 -0
  84. data/lib/desiru/persistence/repositories/base_repository.rb +77 -0
  85. data/lib/desiru/persistence/repositories/job_result_repository.rb +116 -0
  86. data/lib/desiru/persistence/repositories/module_execution_repository.rb +85 -0
  87. data/lib/desiru/persistence/repositories/optimization_result_repository.rb +67 -0
  88. data/lib/desiru/persistence/repositories/training_example_repository.rb +102 -0
  89. data/lib/desiru/persistence/repository.rb +29 -0
  90. data/lib/desiru/persistence/setup.rb +77 -0
  91. data/lib/desiru/persistence.rb +49 -0
  92. data/lib/desiru/registry.rb +3 -5
  93. data/lib/desiru/signature.rb +91 -24
  94. data/lib/desiru/version.rb +1 -1
  95. data/lib/desiru.rb +23 -8
  96. data/missing-features-analysis.md +192 -0
  97. metadata +63 -45
  98. data/lib/desiru/models/raix_adapter.rb +0 -210
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'retry_strategies'
4
+
5
+ module Desiru
6
+ module Jobs
7
+ # Mixin for adding advanced retry capabilities to jobs
8
+ module Retriable
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ # Configure retry policy for the job class
15
+ def retry_policy(policy = nil)
16
+ if policy
17
+ @retry_policy = policy
18
+ else
19
+ @retry_policy ||= RetryStrategies::RetryPolicy.new
20
+ end
21
+ end
22
+
23
+ # DSL for configuring retry policy
24
+ def configure_retries(max_retries: nil, strategy: nil, retriable: nil, non_retriable: nil)
25
+ policy_options = {}
26
+ policy_options[:max_retries] = max_retries if max_retries
27
+ policy_options[:retry_strategy] = strategy if strategy
28
+ policy_options[:retriable_errors] = retriable if retriable
29
+ policy_options[:non_retriable_errors] = non_retriable if non_retriable
30
+
31
+ @retry_policy = RetryStrategies::RetryPolicy.new(**policy_options)
32
+ end
33
+ end
34
+
35
+ # Wrap job execution with retry logic
36
+ def perform_with_retries(*args)
37
+ retry_count = 0
38
+ job_id = args.first if args.first.is_a?(String)
39
+
40
+ begin
41
+ # Track retry count in job result if persistence is enabled
42
+ if job_id && respond_to?(:persistence_enabled?) && persistence_enabled?
43
+ update_retry_count(job_id, retry_count)
44
+ end
45
+
46
+ perform_without_retries(*args)
47
+ rescue StandardError => e
48
+ policy = self.class.retry_policy
49
+
50
+ if policy.should_retry?(retry_count, e)
51
+ retry_count += 1
52
+ delay = policy.retry_delay(retry_count)
53
+
54
+ log_retry(e, retry_count, delay)
55
+
56
+ # Schedule retry with delay
57
+ self.class.perform_in(delay, *args)
58
+
59
+ # Don't re-raise - we've scheduled a retry
60
+ nil
61
+ else
62
+ # Max retries exceeded or non-retriable error
63
+ log_retry_failure(e, retry_count)
64
+
65
+ # Mark job as failed if persistence is enabled
66
+ persist_error_to_db(job_id, e, e.backtrace) if job_id && respond_to?(:persist_error_to_db)
67
+
68
+ # Re-raise to let Sidekiq handle it
69
+ raise
70
+ end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def update_retry_count(job_id, count)
77
+ return unless respond_to?(:job_repo) && job_repo
78
+
79
+ job_result = job_repo.find_by_job_id(job_id)
80
+ job_result&.update(retry_count: count)
81
+ rescue StandardError => e
82
+ Desiru.logger.warn("Failed to update retry count: #{e.message}")
83
+ end
84
+
85
+ def log_retry(error, retry_count, delay)
86
+ Desiru.logger.warn(
87
+ "Retrying #{self.class.name} after error: #{error.message}. " \
88
+ "Retry #{retry_count}, waiting #{delay.round(2)}s"
89
+ )
90
+ end
91
+
92
+ def log_retry_failure(error, retry_count)
93
+ Desiru.logger.error(
94
+ "#{self.class.name} failed after #{retry_count} retries: #{error.message}"
95
+ )
96
+ end
97
+ end
98
+
99
+ # Enhanced base job with retry capabilities
100
+ class RetriableJob < Base
101
+ include Retriable
102
+
103
+ # Alias the original perform method
104
+ alias perform_without_retries perform
105
+ alias perform perform_with_retries
106
+
107
+ # Default configuration with exponential backoff
108
+ configure_retries(
109
+ max_retries: 5,
110
+ strategy: RetryStrategies::ExponentialBackoff.new,
111
+ non_retriable: [
112
+ ArgumentError,
113
+ NoMethodError,
114
+ SyntaxError
115
+ ]
116
+ )
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Desiru
4
+ module Jobs
5
+ # Advanced retry strategies for background jobs
6
+ module RetryStrategies
7
+ # Exponential backoff with jitter
8
+ class ExponentialBackoff
9
+ attr_reader :base_delay, :max_delay, :multiplier, :jitter
10
+
11
+ def initialize(base_delay: 1, max_delay: 300, multiplier: 2, jitter: true)
12
+ @base_delay = base_delay
13
+ @max_delay = max_delay
14
+ @multiplier = multiplier
15
+ @jitter = jitter
16
+ end
17
+
18
+ # Calculate delay for the given retry attempt
19
+ def delay_for(retry_count)
20
+ delay = [base_delay * (multiplier**retry_count), max_delay].min
21
+
22
+ if jitter
23
+ # Add random jitter (±25%) to prevent thundering herd
24
+ jitter_amount = delay * 0.25
25
+ jittered_delay = delay + ((rand * 2 * jitter_amount) - jitter_amount)
26
+ # Ensure we don't exceed max_delay even with jitter
27
+ [jittered_delay, max_delay].min
28
+ else
29
+ delay
30
+ end
31
+ end
32
+ end
33
+
34
+ # Linear backoff strategy
35
+ class LinearBackoff
36
+ attr_reader :base_delay, :max_delay, :increment
37
+
38
+ def initialize(base_delay: 1, max_delay: 60, increment: 5)
39
+ @base_delay = base_delay
40
+ @max_delay = max_delay
41
+ @increment = increment
42
+ end
43
+
44
+ def delay_for(retry_count)
45
+ [base_delay + (increment * retry_count), max_delay].min
46
+ end
47
+ end
48
+
49
+ # Fixed delay strategy
50
+ class FixedDelay
51
+ attr_reader :delay
52
+
53
+ def initialize(delay: 5)
54
+ @delay = delay
55
+ end
56
+
57
+ def delay_for(_retry_count)
58
+ delay
59
+ end
60
+ end
61
+
62
+ # Custom retry policy
63
+ class RetryPolicy
64
+ attr_reader :max_retries, :retry_strategy, :retriable_errors, :non_retriable_errors
65
+
66
+ def initialize(
67
+ max_retries: 5,
68
+ retry_strategy: ExponentialBackoff.new,
69
+ retriable_errors: nil,
70
+ non_retriable_errors: nil
71
+ )
72
+ @max_retries = max_retries
73
+ @retry_strategy = retry_strategy
74
+ @retriable_errors = Array(retriable_errors) if retriable_errors
75
+ @non_retriable_errors = Array(non_retriable_errors) if non_retriable_errors
76
+ end
77
+
78
+ # Check if error is retriable
79
+ def retriable?(error)
80
+ # If non-retriable errors are specified, check those first
81
+ return false if non_retriable_errors&.any? { |klass| error.is_a?(klass) }
82
+
83
+ # If retriable errors are specified, only retry those
84
+ if retriable_errors
85
+ # Only retry if the error matches one of the specified retriable errors
86
+ retriable_errors.any? { |klass| error.is_a?(klass) }
87
+ else
88
+ # By default, retry all errors except non-retriable ones
89
+ true
90
+ end
91
+ end
92
+
93
+ # Check if we should retry based on count
94
+ def should_retry?(retry_count, error)
95
+ retry_count < max_retries && retriable?(error)
96
+ end
97
+
98
+ # Get delay for the current retry
99
+ def retry_delay(retry_count)
100
+ retry_strategy.delay_for(retry_count)
101
+ end
102
+ end
103
+
104
+ # Circuit breaker pattern
105
+ class CircuitBreaker
106
+ attr_reader :failure_threshold, :timeout, :half_open_requests
107
+
108
+ def initialize(failure_threshold: 5, timeout: 60, half_open_requests: 1)
109
+ @failure_threshold = failure_threshold
110
+ @timeout = timeout
111
+ @half_open_requests = half_open_requests
112
+ @state = :closed
113
+ @failure_count = 0
114
+ @last_failure_time = nil
115
+ @half_open_count = 0
116
+ end
117
+
118
+ def call
119
+ case @state
120
+ when :open
121
+ raise CircuitOpenError, "Circuit breaker is open" unless Time.now - @last_failure_time >= timeout
122
+
123
+ @state = :half_open
124
+ @half_open_count = 0
125
+
126
+ end
127
+
128
+ begin
129
+ result = yield
130
+ on_success
131
+ result
132
+ rescue StandardError => e
133
+ on_failure
134
+ raise e
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def on_success
141
+ case @state
142
+ when :half_open
143
+ @half_open_count += 1
144
+ if @half_open_count >= half_open_requests
145
+ @state = :closed
146
+ @failure_count = 0
147
+ end
148
+ when :closed
149
+ @failure_count = 0
150
+ end
151
+ end
152
+
153
+ def on_failure
154
+ @failure_count += 1
155
+ @last_failure_time = Time.now
156
+
157
+ case @state
158
+ when :closed
159
+ @state = :open if @failure_count >= failure_threshold
160
+ when :half_open
161
+ @state = :open
162
+ end
163
+ end
164
+
165
+ class CircuitOpenError < StandardError; end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Desiru
6
+ module Jobs
7
+ # Simple cron-like scheduler for Sidekiq jobs
8
+ class Scheduler
9
+ include Singleton
10
+
11
+ attr_reader :jobs
12
+
13
+ def initialize
14
+ @jobs = {}
15
+ @running = false
16
+ @thread = nil
17
+ end
18
+
19
+ # Schedule a job to run periodically
20
+ # @param name [String] unique name for the scheduled job
21
+ # @param job_class [Class] the job class to execute
22
+ # @param cron [String] cron expression or simple interval
23
+ # @param args [Array] arguments to pass to the job
24
+ # @param options [Hash] additional options
25
+ def schedule(name, job_class:, cron:, args: [], **options)
26
+ @jobs[name] = {
27
+ job_class: job_class,
28
+ cron: cron,
29
+ args: args,
30
+ options: options,
31
+ last_run: nil,
32
+ next_run: calculate_next_run(cron, nil)
33
+ }
34
+ end
35
+
36
+ # Remove a scheduled job
37
+ def unschedule(name)
38
+ @jobs.delete(name)
39
+ end
40
+
41
+ # Start the scheduler
42
+ def start
43
+ return if @running
44
+
45
+ @running = true
46
+ @thread = Thread.new do
47
+ while @running
48
+ check_and_run_jobs
49
+ sleep 1 # Check every second
50
+ end
51
+ end
52
+ end
53
+
54
+ # Stop the scheduler
55
+ def stop
56
+ @running = false
57
+ @thread&.join
58
+ end
59
+
60
+ # Check if scheduler is running
61
+ def running?
62
+ @running
63
+ end
64
+
65
+ # Clear all scheduled jobs
66
+ def clear
67
+ @jobs.clear
68
+ end
69
+
70
+ # Get information about a scheduled job
71
+ def job_info(name)
72
+ @jobs[name]
73
+ end
74
+
75
+ private
76
+
77
+ def check_and_run_jobs
78
+ current_time = Time.now
79
+
80
+ @jobs.each do |name, job_config|
81
+ next unless should_run?(job_config, current_time)
82
+
83
+ run_job(name, job_config)
84
+ job_config[:last_run] = current_time
85
+ job_config[:next_run] = calculate_next_run(job_config[:cron], current_time)
86
+ end
87
+ rescue StandardError => e
88
+ Desiru.logger.error("Scheduler error: #{e.message}")
89
+ end
90
+
91
+ def should_run?(job_config, current_time)
92
+ job_config[:next_run] && current_time >= job_config[:next_run]
93
+ end
94
+
95
+ def run_job(name, job_config)
96
+ job_class = job_config[:job_class]
97
+ args = job_config[:args]
98
+
99
+ # Generate unique job ID for scheduled jobs
100
+ job_id = "scheduled-#{name}-#{Time.now.to_i}"
101
+
102
+ # Enqueue the job
103
+ if job_class.respond_to?(:perform_async)
104
+ if args.empty?
105
+ job_class.perform_async(job_id)
106
+ else
107
+ job_class.perform_async(job_id, *args)
108
+ end
109
+
110
+ Desiru.logger.info("Scheduled job #{name} enqueued with ID: #{job_id}")
111
+ else
112
+ Desiru.logger.error("Job class #{job_class} does not respond to perform_async")
113
+ end
114
+ rescue StandardError => e
115
+ Desiru.logger.error("Failed to enqueue scheduled job #{name}: #{e.message}")
116
+ end
117
+
118
+ def calculate_next_run(cron_expression, last_run)
119
+ case cron_expression
120
+ when /^\d+$/ # Simple interval in seconds
121
+ interval = cron_expression.to_i
122
+ base_time = last_run || Time.now
123
+ base_time + interval
124
+ when /^every (\d+) (second|minute|hour|day)s?$/i
125
+ # Handle simple interval expressions like "every 5 minutes"
126
+ amount = ::Regexp.last_match(1).to_i
127
+ unit = ::Regexp.last_match(2).downcase
128
+
129
+ interval = case unit
130
+ when 'second' then amount
131
+ when 'minute' then amount * 60
132
+ when 'hour' then amount * 3600
133
+ when 'day' then amount * 86_400
134
+ end
135
+
136
+ base_time = last_run || Time.now
137
+ base_time + interval
138
+ else
139
+ # For now, we'll support simple cron patterns
140
+ parse_cron_expression(cron_expression, last_run)
141
+ end
142
+ end
143
+
144
+ def parse_cron_expression(cron_expression, last_run)
145
+ # Simple cron parser for common patterns
146
+ # Format: minute hour day month weekday
147
+ parts = cron_expression.split
148
+
149
+ case parts.length
150
+ when 5
151
+ # Full cron expression - for now, just support simple patterns
152
+ minute, hour, = parts
153
+
154
+ if minute == '*' && hour == '*'
155
+ # Every minute
156
+ (last_run || Time.now) + 60
157
+ elsif minute =~ /^\d+$/ && hour == '*'
158
+ # Every hour at specific minute
159
+ next_time = last_run || Time.now
160
+ next_time + (((60 - next_time.min + minute.to_i) % 60) * 60)
161
+
162
+ elsif minute =~ /^\d+$/ && hour =~ /^\d+$/
163
+ # Daily at specific time
164
+ target_hour = hour.to_i
165
+ target_minute = minute.to_i
166
+
167
+ next_time = Time.now
168
+ next_time = Time.new(next_time.year, next_time.month, next_time.day, target_hour, target_minute, 0)
169
+
170
+ # If we've already passed this time today, schedule for tomorrow
171
+ if next_time <= Time.now
172
+ next_time += 86_400 # Add one day
173
+ end
174
+
175
+ next_time
176
+ else
177
+ # Unsupported pattern, default to hourly
178
+ (last_run || Time.now) + 3600
179
+ end
180
+ else
181
+ # Invalid cron expression, default to hourly
182
+ Desiru.logger.warn("Invalid cron expression: #{cron_expression}, defaulting to hourly")
183
+ (last_run || Time.now) + 3600
184
+ end
185
+ end
186
+ end
187
+
188
+ # Mixin for making jobs schedulable
189
+ module Schedulable
190
+ def self.included(base)
191
+ base.extend(ClassMethods)
192
+ end
193
+
194
+ module ClassMethods
195
+ # Schedule this job to run periodically
196
+ def schedule(cron:, name: nil, args: [], **)
197
+ job_name = name || self.name
198
+ Scheduler.instance.schedule(job_name,
199
+ job_class: self,
200
+ cron: cron,
201
+ args: args,
202
+ **)
203
+ end
204
+
205
+ # Remove this job from the schedule
206
+ def unschedule(name: nil)
207
+ job_name = name || self.name
208
+ Scheduler.instance.unschedule(job_name)
209
+ end
210
+
211
+ # Check if this job is scheduled
212
+ def scheduled?(name: nil)
213
+ job_name = name || self.name
214
+ Scheduler.instance.job_info(job_name) != nil
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end