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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Desiru
4
+ module Optimizers
5
+ # COPRO (Cooperative Prompt Optimization) optimizer
6
+ # Generates and refines instructions for each module using coordinate ascent
7
+ class COPRO < Base
8
+ def initialize(config = {})
9
+ super
10
+ @max_iterations = config[:max_iterations] || 10
11
+ @num_candidates = config[:num_candidates] || 5
12
+ @temperature = config[:temperature] || 0.7
13
+ @improvement_threshold = config[:improvement_threshold] || 0.01
14
+ end
15
+
16
+ def compile(program, trainset, valset = nil, **kwargs)
17
+ valset ||= trainset # Use trainset for validation if no valset provided
18
+
19
+ # Initialize best score
20
+ best_score = evaluate_program(program, valset, kwargs[:metric])
21
+ best_program = program.dup
22
+
23
+ Desiru.logger.info("[COPRO] Initial score: #{best_score}")
24
+
25
+ # Iterate through optimization rounds
26
+ @max_iterations.times do |iteration|
27
+ Desiru.logger.info("[COPRO] Starting iteration #{iteration + 1}/#{@max_iterations}")
28
+
29
+ # Try to improve each predictor
30
+ improved = false
31
+
32
+ program.predictors.each do |name, predictor|
33
+ Desiru.logger.info("[COPRO] Optimizing predictor: #{name}")
34
+
35
+ # Generate instruction candidates
36
+ candidates = generate_instruction_candidates(predictor, trainset, name)
37
+
38
+ # Evaluate each candidate
39
+ best_candidate_score = best_score
40
+ best_candidate_instruction = nil
41
+
42
+ candidates.each do |instruction|
43
+ # Create program with new instruction
44
+ candidate_program = create_program_with_instruction(
45
+ best_program,
46
+ name,
47
+ instruction
48
+ )
49
+
50
+ # Evaluate
51
+ score = evaluate_program(candidate_program, valset, kwargs[:metric])
52
+
53
+ if score > best_candidate_score
54
+ best_candidate_score = score
55
+ best_candidate_instruction = instruction
56
+ end
57
+ end
58
+
59
+ # Update if improved
60
+ next unless best_candidate_instruction && (best_candidate_score - best_score) > @improvement_threshold
61
+
62
+ Desiru.logger.info("[COPRO] Improved #{name}: #{best_score} -> #{best_candidate_score}")
63
+ best_program = create_program_with_instruction(
64
+ best_program,
65
+ name,
66
+ best_candidate_instruction
67
+ )
68
+ best_score = best_candidate_score
69
+ improved = true
70
+ end
71
+
72
+ # Early stopping if no improvement
73
+ break unless improved
74
+ end
75
+
76
+ Desiru.logger.info("[COPRO] Final score: #{best_score}")
77
+ best_program
78
+ end
79
+
80
+ private
81
+
82
+ def generate_instruction_candidates(predictor, trainset, predictor_name)
83
+ candidates = []
84
+
85
+ # Get examples of good performance
86
+ good_examples = select_good_examples(predictor, trainset)
87
+
88
+ # Generate initial instruction based on signature
89
+ signature = predictor.signature
90
+ base_instruction = generate_base_instruction(signature, predictor_name)
91
+ candidates << base_instruction
92
+
93
+ # Generate variations
94
+ (@num_candidates - 1).times do |i|
95
+ variation_prompt = build_variation_prompt(
96
+ base_instruction,
97
+ signature,
98
+ good_examples,
99
+ i
100
+ )
101
+
102
+ response = model.complete(
103
+ messages: [{ role: 'user', content: variation_prompt }],
104
+ temperature: @temperature
105
+ )
106
+
107
+ instruction = extract_instruction(response[:content])
108
+ candidates << instruction if instruction
109
+ end
110
+
111
+ candidates.compact.uniq
112
+ end
113
+
114
+ def generate_base_instruction(signature, predictor_name)
115
+ instruction = "You are solving a #{predictor_name} task.\n\n"
116
+
117
+ # Add input description
118
+ if signature.input_fields.any?
119
+ instruction += "Given the following inputs:\n"
120
+ signature.input_fields.each do |name, field|
121
+ instruction += "- #{name}: #{field.description || field.type}\n"
122
+ end
123
+ instruction += "\n"
124
+ end
125
+
126
+ # Add output description
127
+ if signature.output_fields.any?
128
+ instruction += "Produce the following outputs:\n"
129
+ signature.output_fields.each do |name, field|
130
+ instruction += "- #{name}: #{field.description || field.type}\n"
131
+ end
132
+ end
133
+
134
+ instruction
135
+ end
136
+
137
+ def build_variation_prompt(base_instruction, signature, good_examples, variation_index)
138
+ prompt = "Improve the following instruction for better performance:\n\n"
139
+ prompt += "Current instruction:\n#{base_instruction}\n\n"
140
+
141
+ # Add task context
142
+ prompt += "Task signature: #{signature}\n\n"
143
+
144
+ # Add examples of good performance
145
+ if good_examples.any?
146
+ prompt += "Examples of successful completions:\n"
147
+ good_examples.take(3).each do |example|
148
+ prompt += format_example(example)
149
+ end
150
+ end
151
+
152
+ # Request specific type of improvement
153
+ improvement_types = [
154
+ "Make the instruction more specific and detailed",
155
+ "Add helpful constraints or guidelines",
156
+ "Clarify any ambiguous requirements",
157
+ "Add examples or patterns to follow",
158
+ "Emphasize important aspects of the task"
159
+ ]
160
+
161
+ prompt += "\n#{improvement_types[variation_index % improvement_types.length]}.\n"
162
+ prompt += "Provide only the improved instruction:"
163
+
164
+ prompt
165
+ end
166
+
167
+ def select_good_examples(predictor, trainset)
168
+ good_examples = []
169
+
170
+ trainset.each do |example|
171
+ # Run predictor on example inputs
172
+ result = predictor.call(example[:inputs])
173
+
174
+ # Check if output matches expected
175
+ good_examples << example if outputs_match?(result, example[:outputs])
176
+ rescue StandardError
177
+ # Skip failed examples
178
+ end
179
+
180
+ good_examples
181
+ end
182
+
183
+ def outputs_match?(actual, expected)
184
+ return false unless actual.is_a?(Hash) && expected.is_a?(Hash)
185
+
186
+ expected.all? do |key, expected_value|
187
+ actual_value = actual[key]
188
+
189
+ # Flexible matching for different types
190
+ case expected_value
191
+ when String
192
+ actual_value.to_s.strip.downcase == expected_value.strip.downcase
193
+ when Numeric
194
+ (actual_value.to_f - expected_value.to_f).abs < 0.001
195
+ else
196
+ actual_value == expected_value
197
+ end
198
+ end
199
+ end
200
+
201
+ def format_example(example)
202
+ formatted = "\nExample:\n"
203
+
204
+ if example[:inputs]
205
+ formatted += "Inputs: "
206
+ formatted += example[:inputs].map { |k, v| "#{k}=#{v}" }.join(", ")
207
+ formatted += "\n"
208
+ end
209
+
210
+ if example[:outputs]
211
+ formatted += "Outputs: "
212
+ formatted += example[:outputs].map { |k, v| "#{k}=#{v}" }.join(", ")
213
+ formatted += "\n"
214
+ end
215
+
216
+ formatted
217
+ end
218
+
219
+ def extract_instruction(response)
220
+ # Clean up the response
221
+ instruction = response.strip
222
+
223
+ # Remove any meta-commentary
224
+ instruction = instruction.sub(/^(Here's |This is )?the improved instruction:?\s*/i, '')
225
+ instruction = instruction.sub(/^Improved instruction:?\s*/i, '')
226
+
227
+ # Remove quotes if wrapped
228
+ instruction.gsub(/^["']|["']$/, '')
229
+ end
230
+
231
+ def create_program_with_instruction(program, predictor_name, instruction)
232
+ new_program = program.dup
233
+
234
+ # Get the predictor
235
+ predictor = new_program.predictors[predictor_name]
236
+ return new_program unless predictor
237
+
238
+ # Create new predictor with updated instruction
239
+ new_predictor = predictor.dup
240
+ new_predictor.instance_variable_set(:@instruction, instruction)
241
+
242
+ # Update the program
243
+ new_program.instance_variable_set("@#{predictor_name}", new_predictor)
244
+
245
+ new_program
246
+ end
247
+
248
+ def evaluate_program(program, dataset, metric)
249
+ scores = []
250
+
251
+ dataset.each do |example|
252
+ # Run program
253
+ prediction = program.forward(**example[:inputs])
254
+
255
+ # Calculate score
256
+ score = metric.call(prediction, example[:outputs])
257
+ scores << score
258
+ rescue StandardError => e
259
+ Desiru.logger.debug("[COPRO] Evaluation error: #{e.message}")
260
+ scores << 0.0
261
+ end
262
+
263
+ # Return average score
264
+ scores.empty? ? 0.0 : scores.sum.to_f / scores.length
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Desiru
4
+ module Optimizers
5
+ # KNNFewShot optimizer that uses k-Nearest Neighbors to find similar examples
6
+ # for few-shot learning. It finds the most similar training examples to each
7
+ # input and uses them as demonstrations.
8
+ class KNNFewShot < Base
9
+ def initialize(config = {})
10
+ super
11
+ @k = config[:k] || 3 # Number of nearest neighbors
12
+ @similarity_metric = config[:similarity_metric] || :cosine
13
+ @embedding_cache = {}
14
+ end
15
+
16
+ def compile(program, trainset, **_kwargs)
17
+ # Build index of training examples
18
+ build_example_index(trainset)
19
+
20
+ # Create optimized program with KNN-based few-shot selection
21
+ optimized_program = program.dup
22
+
23
+ # Wrap each predict module with KNN few-shot enhancement
24
+ optimized_program.predictors.each do |name, predictor|
25
+ optimized_predictor = create_knn_predictor(predictor, name)
26
+ optimized_program.instance_variable_set("@#{name}", optimized_predictor)
27
+ end
28
+
29
+ optimized_program
30
+ end
31
+
32
+ private
33
+
34
+ def build_example_index(trainset)
35
+ @example_embeddings = []
36
+ @example_data = []
37
+
38
+ trainset.each do |example|
39
+ # Create text representation of the example
40
+ example_text = serialize_example(example)
41
+
42
+ # Generate embedding (simplified - in practice, use a real embedding model)
43
+ embedding = generate_embedding(example_text)
44
+
45
+ @example_embeddings << embedding
46
+ @example_data << example
47
+ end
48
+ end
49
+
50
+ def create_knn_predictor(original_predictor, predictor_name)
51
+ knn_predictor = original_predictor.dup
52
+ example_embeddings = @example_embeddings
53
+ example_data = @example_data
54
+ k = @k
55
+ similarity_metric = @similarity_metric
56
+
57
+ # Override the forward method to include KNN examples
58
+ knn_predictor.define_singleton_method(:forward) do |**inputs|
59
+ # Find nearest neighbors for this input
60
+ input_text = inputs.map { |k, v| "#{k}: #{v}" }.join("\n")
61
+ input_embedding = generate_embedding(input_text)
62
+
63
+ nearest_examples = find_nearest_neighbors(
64
+ input_embedding,
65
+ example_embeddings,
66
+ example_data,
67
+ k,
68
+ similarity_metric
69
+ )
70
+
71
+ # Format examples for few-shot learning
72
+ demonstrations = format_demonstrations(nearest_examples, predictor_name)
73
+
74
+ # Enhance the prompt with demonstrations
75
+ enhanced_prompt = build_enhanced_prompt(inputs, demonstrations)
76
+
77
+ # Call original predictor with enhanced prompt
78
+ super(**inputs, few_shot_examples: enhanced_prompt)
79
+ end
80
+
81
+ knn_predictor
82
+ end
83
+
84
+ def generate_embedding(text)
85
+ # Cache embeddings to avoid recomputation
86
+ return @embedding_cache[text] if @embedding_cache.key?(text)
87
+
88
+ # Simplified embedding generation
89
+ # In practice, use a real embedding model like OpenAI's text-embedding-ada-002
90
+ words = text.downcase.split(/\W+/)
91
+ embedding = Array.new(100, 0.0)
92
+
93
+ words.each do |word|
94
+ # Simple hash-based pseudo-embedding
95
+ hash_value = word.hash
96
+ 100.times do |i|
97
+ embedding[i] += Math.sin(hash_value * (i + 1)) / Math.sqrt(words.length + 1)
98
+ end
99
+ end
100
+
101
+ # Normalize
102
+ magnitude = Math.sqrt(embedding.sum { |x| x * x })
103
+ embedding = embedding.map { |x| x / (magnitude + 1e-10) }
104
+
105
+ @embedding_cache[text] = embedding
106
+ embedding
107
+ end
108
+
109
+ def find_nearest_neighbors(query_embedding, embeddings, data, num_neighbors, metric)
110
+ # Calculate distances
111
+ distances = embeddings.map.with_index do |embedding, idx|
112
+ distance = case metric
113
+ when :cosine
114
+ cosine_distance(query_embedding, embedding)
115
+ when :euclidean
116
+ euclidean_distance(query_embedding, embedding)
117
+ else
118
+ raise ArgumentError, "Unknown similarity metric: #{metric}"
119
+ end
120
+ { distance: distance, index: idx }
121
+ end
122
+
123
+ # Sort by distance and take top k
124
+ nearest = distances.sort_by { |d| d[:distance] }.take(num_neighbors)
125
+ nearest.map { |d| data[d[:index]] }
126
+ end
127
+
128
+ def cosine_distance(vec1, vec2)
129
+ dot_product = vec1.zip(vec2).sum { |a, b| a * b }
130
+ 1.0 - dot_product # Convert similarity to distance
131
+ end
132
+
133
+ def euclidean_distance(vec1, vec2)
134
+ Math.sqrt(vec1.zip(vec2).sum { |a, b| (a - b)**2 })
135
+ end
136
+
137
+ def serialize_example(example)
138
+ parts = []
139
+
140
+ # Add inputs
141
+ if example[:inputs]
142
+ parts << "Inputs:"
143
+ example[:inputs].each { |k, v| parts << " #{k}: #{v}" }
144
+ end
145
+
146
+ # Add expected outputs
147
+ if example[:outputs]
148
+ parts << "Outputs:"
149
+ example[:outputs].each { |k, v| parts << " #{k}: #{v}" }
150
+ end
151
+
152
+ parts.join("\n")
153
+ end
154
+
155
+ def format_demonstrations(examples, _predictor_name)
156
+ demonstrations = []
157
+
158
+ examples.each_with_index do |example, idx|
159
+ demo = "Example #{idx + 1}:\n"
160
+
161
+ if example[:inputs]
162
+ demo += "Input:\n"
163
+ example[:inputs].each { |k, v| demo += " #{k}: #{v}\n" }
164
+ end
165
+
166
+ if example[:outputs]
167
+ demo += "Output:\n"
168
+ example[:outputs].each { |k, v| demo += " #{k}: #{v}\n" }
169
+ end
170
+
171
+ demonstrations << demo
172
+ end
173
+
174
+ demonstrations.join("\n---\n")
175
+ end
176
+
177
+ def build_enhanced_prompt(_inputs, demonstrations)
178
+ prompt = "Here are some similar examples:\n\n"
179
+ prompt += demonstrations
180
+ prompt += "\n\nNow, given the following input, provide the output:\n"
181
+ prompt
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+ require 'logger'
5
+
6
+ module Desiru
7
+ module Persistence
8
+ # Database connection and migration management
9
+ module Database
10
+ class << self
11
+ attr_reader :connection
12
+
13
+ def connect(database_url = nil)
14
+ url = database_url || Persistence.database_url
15
+
16
+ @connection = Sequel.connect(
17
+ url,
18
+ logger: logger,
19
+ max_connections: max_connections
20
+ )
21
+
22
+ # Enable foreign keys for SQLite
23
+ @connection.run('PRAGMA foreign_keys = ON') if sqlite?
24
+
25
+ @connection
26
+ end
27
+
28
+ def disconnect
29
+ @connection&.disconnect
30
+ @connection = nil
31
+ end
32
+
33
+ def migrate!
34
+ raise 'Not connected to database' unless @connection
35
+
36
+ Sequel.extension :migration
37
+ migrations_path = File.expand_path('../../../db/migrations', __dir__)
38
+ Sequel::Migrator.run(@connection, migrations_path)
39
+
40
+ # Initialize persistence layer after migrations
41
+ require_relative 'setup'
42
+ Setup.initialize!(@connection)
43
+ end
44
+
45
+ def transaction(&)
46
+ raise 'Not connected to database' unless @connection
47
+
48
+ @connection.transaction(&)
49
+ end
50
+
51
+ private
52
+
53
+ def logger
54
+ return nil unless ENV['DESIRU_DEBUG'] || ENV['DEBUG']
55
+
56
+ Logger.new($stdout).tap do |logger|
57
+ logger.level = Logger::INFO
58
+ end
59
+ end
60
+
61
+ def max_connections
62
+ ENV['DESIRU_DB_MAX_CONNECTIONS']&.to_i || 10
63
+ end
64
+
65
+ def sqlite?
66
+ @connection&.adapter_scheme == :sqlite
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Desiru
4
+ module Persistence
5
+ module Models
6
+ # Tracks REST API requests
7
+ class ApiRequest < Base
8
+ set_dataset :api_requests
9
+ one_to_many :module_executions
10
+
11
+ json_column :headers
12
+ json_column :params
13
+ json_column :response_body
14
+
15
+ def validate
16
+ super
17
+ # Validate method column separately due to name conflict with Ruby's method method
18
+ if self[:method].nil? || self[:method].to_s.empty?
19
+ errors.add(:method, 'is required')
20
+ elsif !%w[GET POST PUT PATCH DELETE].include?(self[:method])
21
+ errors.add(:method, 'must be GET, POST, PUT, PATCH, or DELETE')
22
+ end
23
+ validates_presence %i[path status_code]
24
+ end
25
+
26
+ def success?
27
+ status_code >= 200 && status_code < 300
28
+ end
29
+
30
+ def duration_ms
31
+ return nil unless response_time
32
+
33
+ (response_time * 1000).round
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Desiru
4
+ module Persistence
5
+ module Models
6
+ # Model for storing background job results
7
+ class JobResult < Base
8
+ set_dataset :job_results
9
+
10
+ # Status constants
11
+ STATUS_PENDING = 'pending'
12
+ STATUS_PROCESSING = 'processing'
13
+ STATUS_COMPLETED = 'completed'
14
+ STATUS_FAILED = 'failed'
15
+
16
+ # Validations
17
+ def validate
18
+ super
19
+ validates_presence %i[job_id job_class queue status enqueued_at]
20
+ validates_unique :job_id if db&.table_exists?(:job_results)
21
+ validates_includes %w[pending processing completed failed], :status
22
+ end
23
+
24
+ # Scopes
25
+ dataset_module do
26
+ def pending
27
+ where(status: STATUS_PENDING)
28
+ end
29
+
30
+ def processing
31
+ where(status: STATUS_PROCESSING)
32
+ end
33
+
34
+ def completed
35
+ where(status: STATUS_COMPLETED)
36
+ end
37
+
38
+ def failed
39
+ where(status: STATUS_FAILED)
40
+ end
41
+
42
+ def expired
43
+ where { expires_at < Time.now }
44
+ end
45
+
46
+ def active
47
+ where { (expires_at > Time.now) | (expires_at =~ nil) }
48
+ end
49
+
50
+ def by_job_class(job_class)
51
+ where(job_class: job_class)
52
+ end
53
+
54
+ def recent(limit = 10)
55
+ order(Sequel.desc(:created_at)).limit(limit)
56
+ end
57
+ end
58
+
59
+ # Instance methods
60
+ def pending?
61
+ status == STATUS_PENDING
62
+ end
63
+
64
+ def processing?
65
+ status == STATUS_PROCESSING
66
+ end
67
+
68
+ def completed?
69
+ status == STATUS_COMPLETED
70
+ end
71
+
72
+ def failed?
73
+ status == STATUS_FAILED
74
+ end
75
+
76
+ def expired?
77
+ expires_at && expires_at < Time.now
78
+ end
79
+
80
+ def duration
81
+ return nil unless started_at && finished_at
82
+
83
+ finished_at - started_at
84
+ end
85
+
86
+ # JSON field accessors
87
+ def inputs_data
88
+ return {} unless inputs
89
+
90
+ JSON.parse(inputs, symbolize_names: true)
91
+ rescue JSON::ParserError
92
+ {}
93
+ end
94
+
95
+ def result_data
96
+ return {} unless result
97
+
98
+ JSON.parse(result, symbolize_names: true)
99
+ rescue JSON::ParserError
100
+ {}
101
+ end
102
+
103
+ def mark_as_processing!
104
+ update(
105
+ status: STATUS_PROCESSING,
106
+ started_at: Time.now,
107
+ progress: 0
108
+ )
109
+ end
110
+
111
+ def mark_as_completed!(result_data, message: nil)
112
+ update(
113
+ status: STATUS_COMPLETED,
114
+ finished_at: Time.now,
115
+ progress: 100,
116
+ result: result_data.to_json,
117
+ message: message
118
+ )
119
+ end
120
+
121
+ def mark_as_failed!(error, backtrace: nil)
122
+ update(
123
+ status: STATUS_FAILED,
124
+ finished_at: Time.now,
125
+ error_message: error.to_s,
126
+ error_backtrace: backtrace&.join("\n")
127
+ )
128
+ end
129
+
130
+ def update_progress(progress, message: nil)
131
+ updates = { progress: progress }
132
+ updates[:message] = message if message
133
+ update(updates)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end