desiru 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/.claude/settings.local.json +11 -0
- data/.env.example +34 -0
- data/.rubocop.yml +7 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +73 -0
- data/CLAUDE.local.md +3 -0
- data/CLAUDE.md +10 -1
- data/Gemfile +21 -2
- data/Gemfile.lock +88 -13
- data/README.md +301 -2
- data/Rakefile +1 -0
- data/db/migrations/001_create_initial_tables.rb +96 -0
- data/db/migrations/002_create_job_results.rb +39 -0
- data/desiru-development-swarm.yml +185 -0
- data/desiru.db +0 -0
- data/desiru.gemspec +2 -5
- data/docs/background_processing_roadmap.md +87 -0
- data/docs/job_scheduling.md +167 -0
- data/dspy-analysis-swarm.yml +60 -0
- data/dspy-feature-analysis.md +121 -0
- data/examples/README.md +69 -0
- data/examples/api_with_persistence.rb +122 -0
- data/examples/assertions_example.rb +232 -0
- data/examples/async_processing.rb +2 -0
- data/examples/few_shot_learning.rb +1 -2
- data/examples/graphql_api.rb +4 -2
- data/examples/graphql_integration.rb +3 -3
- data/examples/graphql_optimization_summary.md +143 -0
- data/examples/graphql_performance_benchmark.rb +247 -0
- data/examples/persistence_example.rb +102 -0
- data/examples/react_agent.rb +203 -0
- data/examples/rest_api.rb +173 -0
- data/examples/rest_api_advanced.rb +333 -0
- data/examples/scheduled_job_example.rb +116 -0
- data/examples/simple_qa.rb +1 -2
- data/examples/sinatra_api.rb +109 -0
- data/examples/typed_signatures.rb +1 -2
- data/graphql_optimization_summary.md +53 -0
- data/lib/desiru/api/grape_integration.rb +284 -0
- data/lib/desiru/api/persistence_middleware.rb +148 -0
- data/lib/desiru/api/sinatra_integration.rb +217 -0
- data/lib/desiru/api.rb +42 -0
- data/lib/desiru/assertions.rb +74 -0
- data/lib/desiru/async_status.rb +65 -0
- data/lib/desiru/cache.rb +1 -1
- data/lib/desiru/configuration.rb +2 -1
- data/lib/desiru/core/compiler.rb +231 -0
- data/lib/desiru/core/example.rb +96 -0
- data/lib/desiru/core/prediction.rb +108 -0
- data/lib/desiru/core/trace.rb +330 -0
- data/lib/desiru/core/traceable.rb +61 -0
- data/lib/desiru/core.rb +12 -0
- data/lib/desiru/errors.rb +160 -0
- data/lib/desiru/field.rb +17 -14
- data/lib/desiru/graphql/batch_loader.rb +85 -0
- data/lib/desiru/graphql/data_loader.rb +242 -75
- data/lib/desiru/graphql/enum_builder.rb +75 -0
- data/lib/desiru/graphql/executor.rb +37 -4
- data/lib/desiru/graphql/schema_generator.rb +62 -158
- data/lib/desiru/graphql/type_builder.rb +138 -0
- data/lib/desiru/graphql/type_cache_warmer.rb +91 -0
- data/lib/desiru/jobs/async_predict.rb +1 -1
- data/lib/desiru/jobs/base.rb +67 -0
- data/lib/desiru/jobs/batch_processor.rb +6 -6
- data/lib/desiru/jobs/retriable.rb +119 -0
- data/lib/desiru/jobs/retry_strategies.rb +169 -0
- data/lib/desiru/jobs/scheduler.rb +219 -0
- data/lib/desiru/jobs/webhook_notifier.rb +242 -0
- data/lib/desiru/models/anthropic.rb +164 -0
- data/lib/desiru/models/base.rb +37 -3
- data/lib/desiru/models/open_ai.rb +151 -0
- data/lib/desiru/models/open_router.rb +161 -0
- data/lib/desiru/module.rb +67 -9
- data/lib/desiru/modules/best_of_n.rb +306 -0
- data/lib/desiru/modules/chain_of_thought.rb +3 -3
- data/lib/desiru/modules/majority.rb +51 -0
- data/lib/desiru/modules/multi_chain_comparison.rb +256 -0
- data/lib/desiru/modules/predict.rb +15 -1
- data/lib/desiru/modules/program_of_thought.rb +338 -0
- data/lib/desiru/modules/react.rb +273 -0
- data/lib/desiru/modules/retrieve.rb +4 -2
- data/lib/desiru/optimizers/base.rb +32 -4
- data/lib/desiru/optimizers/bootstrap_few_shot.rb +2 -2
- data/lib/desiru/optimizers/copro.rb +268 -0
- data/lib/desiru/optimizers/knn_few_shot.rb +185 -0
- data/lib/desiru/optimizers/mipro_v2.rb +889 -0
- data/lib/desiru/persistence/database.rb +71 -0
- data/lib/desiru/persistence/models/api_request.rb +38 -0
- data/lib/desiru/persistence/models/job_result.rb +138 -0
- data/lib/desiru/persistence/models/module_execution.rb +37 -0
- data/lib/desiru/persistence/models/optimization_result.rb +28 -0
- data/lib/desiru/persistence/models/training_example.rb +25 -0
- data/lib/desiru/persistence/models.rb +11 -0
- data/lib/desiru/persistence/repositories/api_request_repository.rb +98 -0
- data/lib/desiru/persistence/repositories/base_repository.rb +77 -0
- data/lib/desiru/persistence/repositories/job_result_repository.rb +116 -0
- data/lib/desiru/persistence/repositories/module_execution_repository.rb +85 -0
- data/lib/desiru/persistence/repositories/optimization_result_repository.rb +67 -0
- data/lib/desiru/persistence/repositories/training_example_repository.rb +102 -0
- data/lib/desiru/persistence/repository.rb +29 -0
- data/lib/desiru/persistence/setup.rb +77 -0
- data/lib/desiru/persistence.rb +49 -0
- data/lib/desiru/registry.rb +3 -5
- data/lib/desiru/signature.rb +91 -24
- data/lib/desiru/version.rb +1 -1
- data/lib/desiru.rb +33 -8
- data/missing-features-analysis.md +192 -0
- metadata +75 -45
- data/lib/desiru/models/raix_adapter.rb +0 -210
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'grape'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'rack/cors'
|
|
6
|
+
|
|
7
|
+
module Desiru
|
|
8
|
+
module API
|
|
9
|
+
# Grape integration for Desiru - automatically generate REST API endpoints from signatures
|
|
10
|
+
class GrapeIntegration
|
|
11
|
+
attr_reader :modules, :async_enabled, :stream_enabled
|
|
12
|
+
|
|
13
|
+
def initialize(async_enabled: true, stream_enabled: false)
|
|
14
|
+
@modules = {}
|
|
15
|
+
@async_enabled = async_enabled
|
|
16
|
+
@stream_enabled = stream_enabled
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Register a Desiru module with an endpoint path
|
|
20
|
+
def register_module(path, desiru_module, description: nil)
|
|
21
|
+
@modules[path] = {
|
|
22
|
+
module: desiru_module,
|
|
23
|
+
description: description || "Endpoint for #{desiru_module.class.name}"
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Generate a Grape API class with all registered modules
|
|
28
|
+
def generate_api
|
|
29
|
+
modules_config = @modules
|
|
30
|
+
async = @async_enabled
|
|
31
|
+
stream = @stream_enabled
|
|
32
|
+
|
|
33
|
+
Class.new(Grape::API) do
|
|
34
|
+
format :json
|
|
35
|
+
prefix :api
|
|
36
|
+
version 'v1', using: :path
|
|
37
|
+
|
|
38
|
+
# Define class method for type conversion
|
|
39
|
+
def self.grape_type_for(type_string)
|
|
40
|
+
case type_string.to_s.downcase
|
|
41
|
+
when 'integer', 'int'
|
|
42
|
+
Integer
|
|
43
|
+
when 'float'
|
|
44
|
+
Float
|
|
45
|
+
when 'boolean', 'bool'
|
|
46
|
+
Grape::API::Boolean
|
|
47
|
+
when /^list/
|
|
48
|
+
Array
|
|
49
|
+
else
|
|
50
|
+
String # Default to String for unknown types (including 'string', 'str')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
helpers do
|
|
55
|
+
def validate_params(signature, params)
|
|
56
|
+
errors = {}
|
|
57
|
+
|
|
58
|
+
signature.input_fields.each do |name, field|
|
|
59
|
+
value = params[name]
|
|
60
|
+
|
|
61
|
+
# Check required fields
|
|
62
|
+
if value.nil? && !field.optional?
|
|
63
|
+
errors[name] = "is required"
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Type validation
|
|
68
|
+
next unless value && field.type
|
|
69
|
+
|
|
70
|
+
errors[name] = "must be of type #{field.type}" unless validate_type(value, field.type)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
errors
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_type(value, expected_type)
|
|
77
|
+
case expected_type.to_s.downcase
|
|
78
|
+
when 'string', 'str'
|
|
79
|
+
value.is_a?(String)
|
|
80
|
+
when 'integer', 'int'
|
|
81
|
+
value.is_a?(Integer) || (value.is_a?(String) && value.match?(/^\d+$/))
|
|
82
|
+
when 'float'
|
|
83
|
+
value.is_a?(Numeric)
|
|
84
|
+
when 'boolean', 'bool'
|
|
85
|
+
[true, false, 'true', 'false'].include?(value)
|
|
86
|
+
when /^list/
|
|
87
|
+
value.is_a?(Array)
|
|
88
|
+
else
|
|
89
|
+
true # Unknown types and literals pass validation
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def format_response(result)
|
|
94
|
+
if result.is_a?(Hash)
|
|
95
|
+
result
|
|
96
|
+
else
|
|
97
|
+
{ result: result }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_async_request(desiru_module, inputs)
|
|
102
|
+
result = desiru_module.call_async(inputs)
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
job_id: result.job_id,
|
|
106
|
+
status: result.status,
|
|
107
|
+
progress: result.progress,
|
|
108
|
+
status_url: "/api/v1/jobs/#{result.job_id}"
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Health check endpoint
|
|
114
|
+
desc 'Health check'
|
|
115
|
+
get '/health' do
|
|
116
|
+
{ status: 'ok', timestamp: Time.now.iso8601 }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Generate endpoints for each registered module
|
|
120
|
+
modules_config.each do |path, config|
|
|
121
|
+
desiru_module = config[:module]
|
|
122
|
+
description = config[:description]
|
|
123
|
+
|
|
124
|
+
desc description
|
|
125
|
+
params do
|
|
126
|
+
# Generate params from signature
|
|
127
|
+
desiru_module.signature.input_fields.each do |name, field|
|
|
128
|
+
# Convert Desiru types to Grape types
|
|
129
|
+
grape_type = case field.type.to_s.downcase
|
|
130
|
+
when 'integer', 'int'
|
|
131
|
+
Integer
|
|
132
|
+
when 'float'
|
|
133
|
+
Float
|
|
134
|
+
when 'boolean', 'bool'
|
|
135
|
+
Grape::API::Boolean
|
|
136
|
+
when /^list/
|
|
137
|
+
Array
|
|
138
|
+
else
|
|
139
|
+
String # Default to String for unknown types (including 'string', 'str')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
optional name, type: grape_type, desc: field.description
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
post path do
|
|
147
|
+
# Validate parameters
|
|
148
|
+
validation_errors = validate_params(desiru_module.signature, params)
|
|
149
|
+
|
|
150
|
+
error!({ errors: validation_errors }, 422) if validation_errors.any?
|
|
151
|
+
|
|
152
|
+
# Prepare inputs with symbolized keys
|
|
153
|
+
inputs = {}
|
|
154
|
+
desiru_module.signature.input_fields.each_key do |key|
|
|
155
|
+
value = params[key.to_s] || params[key.to_sym]
|
|
156
|
+
inputs[key] = value if value
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
if async && params[:async] == true && desiru_module.respond_to?(:call_async)
|
|
161
|
+
# Handle async request
|
|
162
|
+
status 202
|
|
163
|
+
handle_async_request(desiru_module, inputs)
|
|
164
|
+
elsif params[:async] == true
|
|
165
|
+
# Module doesn't support async
|
|
166
|
+
error!({ error: 'This module does not support async execution' }, 400)
|
|
167
|
+
else
|
|
168
|
+
# Synchronous execution
|
|
169
|
+
result = desiru_module.call(inputs)
|
|
170
|
+
status 201
|
|
171
|
+
format_response(result)
|
|
172
|
+
end
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
error!({ error: e.message }, 500)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Job status endpoint if async is enabled
|
|
180
|
+
if async
|
|
181
|
+
namespace :jobs do
|
|
182
|
+
desc 'Get job status'
|
|
183
|
+
params do
|
|
184
|
+
requires :id, type: String, desc: 'Job ID'
|
|
185
|
+
end
|
|
186
|
+
get ':id' do
|
|
187
|
+
status = Desiru::AsyncStatus.new(params[:id])
|
|
188
|
+
|
|
189
|
+
response = {
|
|
190
|
+
job_id: params[:id],
|
|
191
|
+
status: status.status,
|
|
192
|
+
progress: status.progress
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
response[:result] = status.result if status.ready?
|
|
196
|
+
|
|
197
|
+
response
|
|
198
|
+
rescue StandardError
|
|
199
|
+
error!({ error: "Job not found" }, 404)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Add streaming endpoint support if enabled
|
|
205
|
+
if stream
|
|
206
|
+
namespace :stream do
|
|
207
|
+
modules_config.each do |path, config|
|
|
208
|
+
desiru_module = config[:module]
|
|
209
|
+
description = "#{config[:description]} (streaming)"
|
|
210
|
+
|
|
211
|
+
desc description
|
|
212
|
+
params do
|
|
213
|
+
desiru_module.signature.input_fields.each do |name, field|
|
|
214
|
+
# Convert Desiru types to Grape types
|
|
215
|
+
grape_type = case field.type.to_s.downcase
|
|
216
|
+
when 'integer', 'int'
|
|
217
|
+
Integer
|
|
218
|
+
when 'float'
|
|
219
|
+
Float
|
|
220
|
+
when 'boolean', 'bool'
|
|
221
|
+
Grape::API::Boolean
|
|
222
|
+
when /^list/
|
|
223
|
+
Array
|
|
224
|
+
else
|
|
225
|
+
String # Default to String for unknown types (including 'string', 'str')
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
optional name, type: grape_type, desc: field.description
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
post path do
|
|
233
|
+
content_type 'text/event-stream'
|
|
234
|
+
headers['Cache-Control'] = 'no-cache'
|
|
235
|
+
headers['X-Accel-Buffering'] = 'no'
|
|
236
|
+
status 200
|
|
237
|
+
|
|
238
|
+
stream do |out|
|
|
239
|
+
inputs = {}
|
|
240
|
+
desiru_module.signature.input_fields.each_key do |key|
|
|
241
|
+
inputs[key] = params[key.to_s] if params.key?(key.to_s)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# For now, just send the result as a single event
|
|
245
|
+
# Future: integrate with actual streaming from LLM
|
|
246
|
+
result = desiru_module.call(inputs)
|
|
247
|
+
|
|
248
|
+
out << "event: result\n"
|
|
249
|
+
out << "data: #{JSON.generate(format_response(result))}\n\n"
|
|
250
|
+
|
|
251
|
+
out << "event: done\n"
|
|
252
|
+
out << "data: {\"status\": \"complete\"}\n\n"
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
out << "event: error\n"
|
|
255
|
+
out << "data: #{JSON.generate({ error: e.message })}\n\n"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Mount the API in a Rack application
|
|
265
|
+
def to_rack_app
|
|
266
|
+
api = generate_api
|
|
267
|
+
|
|
268
|
+
Rack::Builder.new do
|
|
269
|
+
use Rack::Cors do
|
|
270
|
+
allow do
|
|
271
|
+
origins '*'
|
|
272
|
+
resource '*',
|
|
273
|
+
headers: :any,
|
|
274
|
+
methods: %i[get post put patch delete options head],
|
|
275
|
+
expose: ['Access-Control-Allow-Origin']
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
run api
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
|
|
5
|
+
module Desiru
|
|
6
|
+
module API
|
|
7
|
+
# Rack middleware for tracking API requests and module executions
|
|
8
|
+
class PersistenceMiddleware
|
|
9
|
+
def initialize(app, enabled: true)
|
|
10
|
+
@app = app
|
|
11
|
+
@enabled = enabled
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
return @app.call(env) unless @enabled && persistence_available?
|
|
16
|
+
|
|
17
|
+
start_time = Time.now
|
|
18
|
+
|
|
19
|
+
# Create request record
|
|
20
|
+
request = Rack::Request.new(env)
|
|
21
|
+
|
|
22
|
+
# Call the app
|
|
23
|
+
status, headers, body = @app.call(env)
|
|
24
|
+
|
|
25
|
+
# Calculate response time
|
|
26
|
+
response_time = Time.now - start_time
|
|
27
|
+
|
|
28
|
+
# Store the request and response
|
|
29
|
+
store_request(request, status, headers, body, response_time)
|
|
30
|
+
|
|
31
|
+
# Return the response
|
|
32
|
+
[status, headers, body]
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
# Log error but don't fail the request
|
|
35
|
+
warn "PersistenceMiddleware error: #{e.message}"
|
|
36
|
+
[status, headers, body] || [500, {}, ['Internal Server Error']]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def persistence_available?
|
|
42
|
+
defined?(Desiru::Persistence) &&
|
|
43
|
+
Desiru::Persistence::Database.connection &&
|
|
44
|
+
Desiru::Persistence::Setup.initialized?
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def store_request(request, status, _headers, body, response_time)
|
|
50
|
+
# Only track API endpoints
|
|
51
|
+
return unless request.path_info.start_with?('/api/')
|
|
52
|
+
|
|
53
|
+
api_requests = Desiru::Persistence[:api_requests]
|
|
54
|
+
|
|
55
|
+
api_request = api_requests.create(
|
|
56
|
+
method: request.request_method,
|
|
57
|
+
path: request.path_info,
|
|
58
|
+
remote_ip: request.ip,
|
|
59
|
+
headers: extract_headers(request),
|
|
60
|
+
params: extract_params(request),
|
|
61
|
+
status_code: status,
|
|
62
|
+
response_body: extract_body(body),
|
|
63
|
+
response_time: response_time
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Store module execution if available
|
|
67
|
+
store_module_execution(api_request.id, request, body) if api_request
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
warn "Failed to store request: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_headers(request)
|
|
73
|
+
headers = {}
|
|
74
|
+
request.each_header do |key, value|
|
|
75
|
+
next unless key.start_with?('HTTP_') || key == 'CONTENT_TYPE'
|
|
76
|
+
|
|
77
|
+
header_name = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-')
|
|
78
|
+
headers[header_name] = value
|
|
79
|
+
end
|
|
80
|
+
headers
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_params(request)
|
|
84
|
+
if request.content_type&.include?('application/json')
|
|
85
|
+
request.body.rewind
|
|
86
|
+
JSON.parse(request.body.read)
|
|
87
|
+
else
|
|
88
|
+
request.params
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError
|
|
91
|
+
request.params
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_body(body)
|
|
95
|
+
return nil unless body.respond_to?(:each)
|
|
96
|
+
|
|
97
|
+
content = body.map { |part| part }
|
|
98
|
+
|
|
99
|
+
# Try to parse as JSON
|
|
100
|
+
JSON.parse(content.join)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
content.join
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def store_module_execution(api_request_id, request, body)
|
|
106
|
+
# Extract module info from path (e.g., /api/v1/summarize -> summarize)
|
|
107
|
+
module_path = request.path_info.gsub(%r{^/api/v\d+/}, '')
|
|
108
|
+
return unless module_path && !module_path.empty?
|
|
109
|
+
|
|
110
|
+
params = extract_params(request)
|
|
111
|
+
result = extract_body(body)
|
|
112
|
+
|
|
113
|
+
module_executions = Desiru::Persistence[:module_executions]
|
|
114
|
+
|
|
115
|
+
execution = module_executions.create_for_module(
|
|
116
|
+
module_path.capitalize,
|
|
117
|
+
params,
|
|
118
|
+
api_request_id: api_request_id
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Mark as completed if we have a result
|
|
122
|
+
if result.is_a?(Hash) && !result['error']
|
|
123
|
+
module_executions.complete(execution.id, result)
|
|
124
|
+
elsif result.is_a?(Hash) && result['error']
|
|
125
|
+
module_executions.fail(execution.id, result['error'])
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
warn "Failed to store module execution: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extension for API integrations to add persistence
|
|
133
|
+
module PersistenceExtension
|
|
134
|
+
def with_persistence(enabled: true)
|
|
135
|
+
original_app = to_rack_app
|
|
136
|
+
|
|
137
|
+
Rack::Builder.new do
|
|
138
|
+
use PersistenceMiddleware, enabled: enabled
|
|
139
|
+
run original_app
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Add extension to API integrations
|
|
147
|
+
Desiru::API::GrapeIntegration.include(Desiru::API::PersistenceExtension) if defined?(Desiru::API::GrapeIntegration)
|
|
148
|
+
Desiru::API::SinatraIntegration.include(Desiru::API::PersistenceExtension) if defined?(Desiru::API::SinatraIntegration)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sinatra/base'
|
|
4
|
+
require 'sinatra/json'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'rack/cors'
|
|
7
|
+
|
|
8
|
+
module Desiru
|
|
9
|
+
module API
|
|
10
|
+
# Sinatra integration for Desiru - lightweight REST API generation
|
|
11
|
+
class SinatraIntegration
|
|
12
|
+
attr_reader :modules, :async_enabled, :stream_enabled
|
|
13
|
+
|
|
14
|
+
def initialize(async_enabled: true, stream_enabled: false)
|
|
15
|
+
@modules = {}
|
|
16
|
+
@async_enabled = async_enabled
|
|
17
|
+
@stream_enabled = stream_enabled
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Register a Desiru module with an endpoint path
|
|
21
|
+
def register_module(path, desiru_module, description: nil)
|
|
22
|
+
@modules[path] = {
|
|
23
|
+
module: desiru_module,
|
|
24
|
+
description: description || "Endpoint for #{desiru_module.class.name}"
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generate a Sinatra application with all registered modules
|
|
29
|
+
def generate_api
|
|
30
|
+
modules_config = @modules
|
|
31
|
+
async = @async_enabled
|
|
32
|
+
stream = @stream_enabled
|
|
33
|
+
|
|
34
|
+
Class.new(Sinatra::Base) do
|
|
35
|
+
set :show_exceptions, false
|
|
36
|
+
set :raise_errors, true
|
|
37
|
+
|
|
38
|
+
# Helpers for parameter validation and response formatting
|
|
39
|
+
helpers do
|
|
40
|
+
def validate_type(value, type_string)
|
|
41
|
+
case type_string.to_s.downcase
|
|
42
|
+
when 'string', 'str'
|
|
43
|
+
value.is_a?(String)
|
|
44
|
+
when 'integer', 'int'
|
|
45
|
+
value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A-?\d+\z/))
|
|
46
|
+
when 'float'
|
|
47
|
+
value.is_a?(Numeric)
|
|
48
|
+
when 'boolean', 'bool'
|
|
49
|
+
[true, false, 'true', 'false'].include?(value)
|
|
50
|
+
when /^list/
|
|
51
|
+
value.is_a?(Array)
|
|
52
|
+
else
|
|
53
|
+
true # Unknown types and literals pass validation
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def coerce_value(value, type_string)
|
|
58
|
+
case type_string.to_s.downcase
|
|
59
|
+
when 'integer', 'int'
|
|
60
|
+
value.to_i
|
|
61
|
+
when 'float'
|
|
62
|
+
value.to_f
|
|
63
|
+
when 'boolean', 'bool'
|
|
64
|
+
['true', true].include?(value)
|
|
65
|
+
else
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format_response(result)
|
|
71
|
+
if result.is_a?(Desiru::ModuleResult)
|
|
72
|
+
result.data
|
|
73
|
+
elsif result.is_a?(Hash)
|
|
74
|
+
result
|
|
75
|
+
else
|
|
76
|
+
{ result: result }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_async_request(desiru_module, inputs)
|
|
81
|
+
result = desiru_module.call_async(inputs)
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
job_id: result.job_id,
|
|
85
|
+
status: result.status,
|
|
86
|
+
progress: result.progress,
|
|
87
|
+
status_url: "/api/v1/jobs/#{result.job_id}"
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_json_body
|
|
92
|
+
request.body.rewind
|
|
93
|
+
JSON.parse(request.body.read)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
halt 400, json(error: 'Invalid JSON')
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Content type handling
|
|
100
|
+
before do
|
|
101
|
+
content_type :json
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Health check endpoint
|
|
105
|
+
get '/api/v1/health' do
|
|
106
|
+
json status: 'ok', timestamp: Time.now.iso8601
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Generate endpoints for each registered module
|
|
110
|
+
modules_config.each do |path, config|
|
|
111
|
+
desiru_module = config[:module]
|
|
112
|
+
|
|
113
|
+
# Main module endpoint
|
|
114
|
+
post "/api/v1#{path}" do
|
|
115
|
+
params = parse_json_body
|
|
116
|
+
|
|
117
|
+
# Convert string keys to symbols for module call
|
|
118
|
+
symbolized_params = {}
|
|
119
|
+
params.each { |k, v| symbolized_params[k.to_sym] = v }
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
result = desiru_module.call(symbolized_params)
|
|
123
|
+
json format_response(result)
|
|
124
|
+
rescue Desiru::ModuleError => e
|
|
125
|
+
halt 400, json(error: e.message)
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
halt 500, json(error: e.message)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Async endpoint
|
|
132
|
+
if async && desiru_module.respond_to?(:call_async)
|
|
133
|
+
post "/api/v1/async#{path}" do
|
|
134
|
+
params = parse_json_body
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
result = handle_async_request(desiru_module, params)
|
|
138
|
+
status 202
|
|
139
|
+
json result
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
halt 500, json(error: e.message)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Streaming endpoint (Server-Sent Events)
|
|
147
|
+
next unless stream && desiru_module.respond_to?(:call_stream)
|
|
148
|
+
|
|
149
|
+
post "/api/v1/stream#{path}" do
|
|
150
|
+
content_type 'text/event-stream'
|
|
151
|
+
stream do |out|
|
|
152
|
+
params = parse_json_body
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
desiru_module.call_stream(params) do |chunk|
|
|
156
|
+
out << "event: chunk\n"
|
|
157
|
+
out << "data: #{JSON.generate(chunk)}\n\n"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Send final result
|
|
161
|
+
result = desiru_module.call(params)
|
|
162
|
+
out << "event: result\n"
|
|
163
|
+
out << "data: #{JSON.generate(format_response(result))}\n\n"
|
|
164
|
+
out << "event: done\n"
|
|
165
|
+
out << "data: #{JSON.generate({ status: 'complete' })}\n\n"
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
out << "event: error\n"
|
|
168
|
+
out << "data: #{JSON.generate(error: e.message)}\n\n"
|
|
169
|
+
ensure
|
|
170
|
+
out.close
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Job status endpoint for async requests
|
|
177
|
+
if async
|
|
178
|
+
get '/api/v1/jobs/:job_id' do
|
|
179
|
+
job_id = params[:job_id]
|
|
180
|
+
|
|
181
|
+
if Desiru.respond_to?(:check_job_status)
|
|
182
|
+
status = Desiru.check_job_status(job_id)
|
|
183
|
+
|
|
184
|
+
if status
|
|
185
|
+
json status
|
|
186
|
+
else
|
|
187
|
+
halt 404, json(error: 'Job not found')
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
halt 501, json(error: 'Async job tracking not implemented')
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Mount the API in a Rack application with CORS
|
|
198
|
+
def to_rack_app
|
|
199
|
+
api = generate_api
|
|
200
|
+
|
|
201
|
+
Rack::Builder.new do
|
|
202
|
+
use Rack::Cors do
|
|
203
|
+
allow do
|
|
204
|
+
origins '*'
|
|
205
|
+
resource '*',
|
|
206
|
+
headers: :any,
|
|
207
|
+
methods: %i[get post put patch delete options head],
|
|
208
|
+
expose: ['Access-Control-Allow-Origin']
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
run api
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
data/lib/desiru/api.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'api/grape_integration'
|
|
4
|
+
require_relative 'api/sinatra_integration'
|
|
5
|
+
require_relative 'api/persistence_middleware'
|
|
6
|
+
|
|
7
|
+
module Desiru
|
|
8
|
+
module API
|
|
9
|
+
# Convenience method to create a new API integration
|
|
10
|
+
# @param framework [Symbol] :grape or :sinatra (default: :grape)
|
|
11
|
+
def self.create(framework: :grape, async_enabled: true, stream_enabled: false, &)
|
|
12
|
+
klass = case framework
|
|
13
|
+
when :grape
|
|
14
|
+
GrapeIntegration
|
|
15
|
+
when :sinatra
|
|
16
|
+
SinatraIntegration
|
|
17
|
+
else
|
|
18
|
+
raise ArgumentError, "Unknown framework: #{framework}. Use :grape or :sinatra"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
integration = klass.new(
|
|
22
|
+
async_enabled: async_enabled,
|
|
23
|
+
stream_enabled: stream_enabled
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Allow DSL-style configuration
|
|
27
|
+
integration.instance_eval(&) if block_given?
|
|
28
|
+
|
|
29
|
+
integration
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Convenience method to create a new Grape API (backward compatibility)
|
|
33
|
+
def self.grape(async_enabled: true, stream_enabled: false, &)
|
|
34
|
+
create(framework: :grape, async_enabled: async_enabled, stream_enabled: stream_enabled, &)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convenience method to create a new Sinatra API
|
|
38
|
+
def self.sinatra(async_enabled: true, stream_enabled: false, &)
|
|
39
|
+
create(framework: :sinatra, async_enabled: async_enabled, stream_enabled: stream_enabled, &)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|