lluminary 0.1.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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/lib/lluminary/config.rb +23 -0
  3. data/lib/lluminary/field_description.rb +148 -0
  4. data/lib/lluminary/provider_error.rb +4 -0
  5. data/lib/lluminary/providers/base.rb +15 -0
  6. data/lib/lluminary/providers/bedrock.rb +51 -0
  7. data/lib/lluminary/providers/openai.rb +40 -0
  8. data/lib/lluminary/providers/test.rb +37 -0
  9. data/lib/lluminary/result.rb +13 -0
  10. data/lib/lluminary/schema.rb +61 -0
  11. data/lib/lluminary/schema_model.rb +87 -0
  12. data/lib/lluminary/task.rb +224 -0
  13. data/lib/lluminary/validation_error.rb +4 -0
  14. data/lib/lluminary/version.rb +3 -0
  15. data/lib/lluminary.rb +18 -0
  16. data/spec/examples/analyze_text_spec.rb +21 -0
  17. data/spec/examples/color_analyzer_spec.rb +42 -0
  18. data/spec/examples/content_analyzer_spec.rb +75 -0
  19. data/spec/examples/historical_event_analyzer_spec.rb +37 -0
  20. data/spec/examples/price_analyzer_spec.rb +46 -0
  21. data/spec/examples/quote_task_spec.rb +27 -0
  22. data/spec/examples/sentiment_analysis_spec.rb +45 -0
  23. data/spec/examples/summarize_text_spec.rb +21 -0
  24. data/spec/lluminary/config_spec.rb +53 -0
  25. data/spec/lluminary/field_description_spec.rb +36 -0
  26. data/spec/lluminary/providers/base_spec.rb +17 -0
  27. data/spec/lluminary/providers/bedrock_spec.rb +109 -0
  28. data/spec/lluminary/providers/openai_spec.rb +62 -0
  29. data/spec/lluminary/providers/test_spec.rb +57 -0
  30. data/spec/lluminary/result_spec.rb +31 -0
  31. data/spec/lluminary/schema_model_spec.rb +86 -0
  32. data/spec/lluminary/schema_spec.rb +302 -0
  33. data/spec/lluminary/task_spec.rb +777 -0
  34. data/spec/spec_helper.rb +4 -0
  35. metadata +190 -0
@@ -0,0 +1,109 @@
1
+ require 'spec_helper'
2
+ require 'lluminary/providers/bedrock'
3
+
4
+ RSpec.describe Lluminary::Providers::Bedrock do
5
+ let(:config) do
6
+ {
7
+ region: 'us-east-1',
8
+ access_key_id: 'test-key',
9
+ secret_access_key: 'test-secret'
10
+ }
11
+ end
12
+ let(:provider) { described_class.new(**config) }
13
+
14
+ describe '#client' do
15
+ it 'returns the AWS Bedrock client instance' do
16
+ expect(provider.client).to be_a(Aws::BedrockRuntime::Client)
17
+ end
18
+ end
19
+
20
+ describe '#call' do
21
+ let(:prompt) { 'Test prompt' }
22
+ let(:task) { 'Test task' }
23
+ let(:mock_response) do
24
+ OpenStruct.new(
25
+ output: OpenStruct.new(
26
+ message: OpenStruct.new(
27
+ content: [
28
+ OpenStruct.new(text: '{"sentiment": "positive"}')
29
+ ]
30
+ )
31
+ )
32
+ )
33
+ end
34
+
35
+ before do
36
+ allow_any_instance_of(Aws::BedrockRuntime::Client).to receive(:converse).and_return(mock_response)
37
+ end
38
+
39
+ it 'returns a hash with raw and parsed response' do
40
+ response = provider.call(prompt, task)
41
+ expect(response).to eq({
42
+ raw: '{"sentiment": "positive"}',
43
+ parsed: { 'sentiment' => 'positive' }
44
+ })
45
+ end
46
+
47
+ context 'when the response is not valid JSON' do
48
+ let(:mock_response) do
49
+ OpenStruct.new(
50
+ output: OpenStruct.new(
51
+ message: OpenStruct.new(
52
+ content: [
53
+ OpenStruct.new(text: 'not valid json')
54
+ ]
55
+ )
56
+ )
57
+ )
58
+ end
59
+
60
+ it 'returns raw response with nil parsed value' do
61
+ response = provider.call(prompt, task)
62
+ expect(response).to eq({
63
+ raw: 'not valid json',
64
+ parsed: nil
65
+ })
66
+ end
67
+ end
68
+
69
+ context 'when the response content is nil' do
70
+ let(:mock_response) do
71
+ OpenStruct.new(
72
+ output: OpenStruct.new(
73
+ message: OpenStruct.new(
74
+ content: nil
75
+ )
76
+ )
77
+ )
78
+ end
79
+
80
+ it 'returns nil for both raw and parsed values' do
81
+ response = provider.call(prompt, task)
82
+ expect(response).to eq({
83
+ raw: nil,
84
+ parsed: nil
85
+ })
86
+ end
87
+ end
88
+
89
+ context 'when the response content array is empty' do
90
+ let(:mock_response) do
91
+ OpenStruct.new(
92
+ output: OpenStruct.new(
93
+ message: OpenStruct.new(
94
+ content: []
95
+ )
96
+ )
97
+ )
98
+ end
99
+
100
+ it 'returns nil for both raw and parsed values' do
101
+ response = provider.call(prompt, task)
102
+ expect(response).to eq({
103
+ raw: nil,
104
+ parsed: nil
105
+ })
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,62 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Lluminary::Providers::OpenAI do
4
+ let(:config) { { api_key: 'test-key' } }
5
+ let(:provider) { described_class.new(**config) }
6
+
7
+ describe '#client' do
8
+ it 'returns the OpenAI client instance' do
9
+ expect(provider.client).to be_a(OpenAI::Client)
10
+ end
11
+ end
12
+
13
+ describe '#call' do
14
+ let(:prompt) { 'Test prompt' }
15
+ let(:task) { 'Test task' }
16
+ let(:mock_response) do
17
+ {
18
+ 'choices' => [
19
+ {
20
+ 'message' => {
21
+ 'content' => '{"summary": "Test response"}'
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ end
27
+
28
+ before do
29
+ allow_any_instance_of(OpenAI::Client).to receive(:chat).and_return(mock_response)
30
+ end
31
+
32
+ it 'returns a hash with raw and parsed response' do
33
+ response = provider.call(prompt, task)
34
+ expect(response).to eq({
35
+ raw: '{"summary": "Test response"}',
36
+ parsed: { 'summary' => 'Test response' }
37
+ })
38
+ end
39
+
40
+ context 'when the response is not valid JSON' do
41
+ let(:mock_response) do
42
+ {
43
+ 'choices' => [
44
+ {
45
+ 'message' => {
46
+ 'content' => 'not valid json'
47
+ }
48
+ }
49
+ ]
50
+ }
51
+ end
52
+
53
+ it 'returns raw response with nil parsed value' do
54
+ response = provider.call(prompt, task)
55
+ expect(response).to eq({
56
+ raw: 'not valid json',
57
+ parsed: nil
58
+ })
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,57 @@
1
+ require 'spec_helper'
2
+ require 'lluminary/providers/test'
3
+
4
+ RSpec.describe Lluminary::Providers::Test do
5
+ let(:provider) { described_class.new }
6
+ let(:prompt) { "Test prompt" }
7
+ let(:task_class) { double("TaskClass", output_fields: { summary: { type: :string } }) }
8
+ let(:task) { double("Task", class: task_class) }
9
+
10
+ describe '#call' do
11
+ it 'returns a hash with raw and parsed response' do
12
+ response = provider.call(prompt, task)
13
+
14
+ expect(response).to be_a(Hash)
15
+ expect(response[:raw]).to eq('{"summary": "Test string value"}')
16
+ expect(response[:parsed]).to eq({ "summary" => "Test string value" })
17
+ end
18
+
19
+ it 'handles prompts with schema descriptions' do
20
+ prompt_with_schema = <<~PROMPT
21
+ Test prompt
22
+
23
+ You must respond with a valid JSON object with the following fields:
24
+
25
+ summary (string): A brief summary of the message
26
+ Example: "your summary here"
27
+
28
+ Your response should look like this:
29
+ {
30
+ "summary": "your summary here"
31
+ }
32
+ PROMPT
33
+
34
+ response = provider.call(prompt_with_schema, task)
35
+ expect(response[:raw]).to eq('{"summary": "Test string value"}')
36
+ expect(response[:parsed]).to eq({ "summary" => "Test string value" })
37
+ end
38
+
39
+ it 'generates integer values for integer fields' do
40
+ task_class = double("TaskClass", output_fields: { count: { type: :integer } })
41
+ task = double("Task", class: task_class)
42
+
43
+ response = provider.call(prompt, task)
44
+ expect(response[:raw]).to eq('{"count": 0}')
45
+ expect(response[:parsed]).to eq({ "count" => 0 })
46
+ end
47
+
48
+ it 'raises error for unsupported types' do
49
+ task_class = double("TaskClass", output_fields: { value: { type: :unsupported } })
50
+ task = double("Task", class: task_class)
51
+
52
+ expect {
53
+ provider.call(prompt, task)
54
+ }.to raise_error("Unsupported type: unsupported")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,31 @@
1
+ require 'lluminary'
2
+
3
+ RSpec.describe Lluminary::Result do
4
+ let(:raw_response) { "Test response" }
5
+ let(:output) { { summary: "Test summary" } }
6
+ let(:prompt) { "Test prompt" }
7
+ let(:result) { described_class.new(raw_response: raw_response, output: output, prompt: prompt) }
8
+
9
+ describe '#raw_response' do
10
+ it 'returns the raw response' do
11
+ expect(result.raw_response).to eq(raw_response)
12
+ end
13
+ end
14
+
15
+ describe '#output' do
16
+ it 'returns an OpenStruct with the output data' do
17
+ expect(result.output).to be_a(OpenStruct)
18
+ expect(result.output.summary).to eq(output[:summary])
19
+ end
20
+
21
+ it 'allows accessing output fields as methods' do
22
+ expect(result.output.summary).to eq(output[:summary])
23
+ end
24
+ end
25
+
26
+ describe '#prompt' do
27
+ it 'returns the prompt' do
28
+ expect(result.prompt).to eq(prompt)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,86 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Lluminary::SchemaModel do
4
+ describe '.build' do
5
+ let(:fields) do
6
+ {
7
+ name: { type: :string, description: "The user's name" },
8
+ age: { type: :integer, description: "The user's age" }
9
+ }
10
+ end
11
+
12
+ let(:validations) do
13
+ [
14
+ [[:name], { presence: true }],
15
+ [[:age], { numericality: { greater_than: 0 } }]
16
+ ]
17
+ end
18
+
19
+ let(:model_class) { described_class.build(fields: fields, validations: validations) }
20
+
21
+ it 'creates a class that inherits from SchemaModel' do
22
+ expect(model_class.ancestors).to include(described_class)
23
+ end
24
+
25
+ it 'includes ActiveModel::Validations' do
26
+ expect(model_class.ancestors).to include(ActiveModel::Validations)
27
+ end
28
+
29
+ it 'adds accessors for fields' do
30
+ instance = model_class.new(name: "John", age: 30)
31
+ expect(instance.name).to eq("John")
32
+ expect(instance.age).to eq(30)
33
+ end
34
+
35
+ it 'validates presence' do
36
+ instance = model_class.new(age: 30)
37
+ expect(instance.valid?).to be false
38
+ expect(instance.errors.full_messages).to include("Name can't be blank")
39
+ end
40
+
41
+ it 'validates numericality' do
42
+ instance = model_class.new(name: "John", age: 0)
43
+ expect(instance.valid?).to be false
44
+ expect(instance.errors.full_messages).to include("Age must be greater than 0")
45
+ end
46
+
47
+ it 'validates types' do
48
+ instance = model_class.new(name: 123, age: "30")
49
+ expect(instance.valid?).to be false
50
+ expect(instance.errors.full_messages).to include(
51
+ "Name must be a String",
52
+ "Age must be an Integer"
53
+ )
54
+ end
55
+
56
+ it 'validates float types' do
57
+ fields = {
58
+ price: { type: :float, description: "The price" }
59
+ }
60
+ model_class = described_class.build(fields: fields, validations: [])
61
+
62
+ # Test that nil is allowed
63
+ instance = model_class.new(price: nil)
64
+ expect(instance.valid?).to be true
65
+
66
+ # Test invalid float value
67
+ instance = model_class.new(price: "not a float")
68
+ expect(instance.valid?).to be false
69
+ expect(instance.errors.full_messages).to include("Price must be a float")
70
+
71
+ # Test valid float value
72
+ instance = model_class.new(price: 12.34)
73
+ expect(instance.valid?).to be true
74
+ end
75
+
76
+ it 'accepts valid attributes' do
77
+ instance = model_class.new(name: "John", age: 30)
78
+ expect(instance.valid?).to be true
79
+ end
80
+
81
+ it 'provides access to raw attributes' do
82
+ instance = model_class.new(name: "John", age: 30)
83
+ expect(instance.attributes).to eq({ "name" => "John", "age" => 30 })
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,302 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Lluminary::Schema do
4
+ let(:schema) { described_class.new }
5
+
6
+ describe '#initialize' do
7
+ it 'creates an empty fields hash' do
8
+ expect(schema.fields).to eq({})
9
+ end
10
+ end
11
+
12
+ describe '#string' do
13
+ it 'adds a string field to the schema' do
14
+ schema.string(:name)
15
+ expect(schema.fields).to eq({ name: { type: :string, description: nil } })
16
+ end
17
+
18
+ it 'adds a string field with description' do
19
+ schema.string(:name, description: "The user's full name")
20
+ expect(schema.fields).to eq({
21
+ name: {
22
+ type: :string,
23
+ description: "The user's full name"
24
+ }
25
+ })
26
+ end
27
+ end
28
+
29
+ describe '#integer' do
30
+ it 'adds an integer field to the schema' do
31
+ schema.integer(:count)
32
+ expect(schema.fields).to eq({ count: { type: :integer, description: nil } })
33
+ end
34
+
35
+ it 'adds an integer field with description' do
36
+ schema.integer(:count, description: "The total number of items")
37
+ expect(schema.fields).to eq({
38
+ count: {
39
+ type: :integer,
40
+ description: "The total number of items"
41
+ }
42
+ })
43
+ end
44
+ end
45
+
46
+ describe '#boolean' do
47
+ it 'adds a boolean field to the schema' do
48
+ schema.boolean(:active)
49
+ expect(schema.fields).to eq({ active: { type: :boolean, description: nil } })
50
+ end
51
+
52
+ it 'adds a boolean field with description' do
53
+ schema.boolean(:active, description: "Whether the item is active")
54
+ expect(schema.fields).to eq({
55
+ active: {
56
+ type: :boolean,
57
+ description: "Whether the item is active"
58
+ }
59
+ })
60
+ end
61
+ end
62
+
63
+ describe '#float' do
64
+ it 'adds a float field to the schema' do
65
+ schema.float(:price)
66
+ expect(schema.fields).to eq({ price: { type: :float, description: nil } })
67
+ end
68
+
69
+ it 'adds a float field with description' do
70
+ schema.float(:price, description: "The price of the item")
71
+ expect(schema.fields).to eq({
72
+ price: {
73
+ type: :float,
74
+ description: "The price of the item"
75
+ }
76
+ })
77
+ end
78
+ end
79
+
80
+ describe '#datetime' do
81
+ it 'adds a datetime field to the schema' do
82
+ schema.datetime(:start_time)
83
+ expect(schema.fields).to eq({ start_time: { type: :datetime, description: nil } })
84
+ end
85
+
86
+ it 'adds a datetime field with description' do
87
+ schema.datetime(:start_time, description: "When the event starts")
88
+ expect(schema.fields).to eq({
89
+ start_time: {
90
+ type: :datetime,
91
+ description: "When the event starts"
92
+ }
93
+ })
94
+ end
95
+ end
96
+
97
+ describe '#fields' do
98
+ it 'returns the fields hash' do
99
+ schema.string(:name)
100
+ expect(schema.fields).to eq({ name: { type: :string, description: nil } })
101
+ end
102
+
103
+ it 'returns the same hash instance' do
104
+ schema.string(:name)
105
+ first_call = schema.fields
106
+ second_call = schema.fields
107
+ expect(first_call).to be(second_call)
108
+ end
109
+
110
+ context 'with datetime fields' do
111
+ let(:schema) do
112
+ described_class.new.tap do |s|
113
+ s.datetime(:start_time)
114
+ end
115
+ end
116
+
117
+ it 'accepts DateTime values' do
118
+ errors = schema.validate(start_time: DateTime.now)
119
+ expect(errors).to be_empty
120
+ end
121
+
122
+ it 'accepts nil values' do
123
+ errors = schema.validate(start_time: nil)
124
+ expect(errors).to be_empty
125
+ end
126
+
127
+ it 'returns errors for non-DateTime values' do
128
+ errors = schema.validate(start_time: "2024-01-01")
129
+ expect(errors).to contain_exactly("Start time must be a DateTime")
130
+ end
131
+
132
+ it 'can be required using presence validation' do
133
+ schema.validates :start_time, presence: true
134
+ errors = schema.validate(start_time: nil)
135
+ expect(errors).to contain_exactly("Start time can't be blank")
136
+ end
137
+ end
138
+ end
139
+
140
+ describe '#validate' do
141
+ let(:schema) do
142
+ described_class.new.tap do |s|
143
+ s.string(:name)
144
+ s.integer(:age)
145
+ end
146
+ end
147
+
148
+ it 'returns no errors when all values match their field types' do
149
+ errors = schema.validate(name: "John", age: 30)
150
+ expect(errors).to be_empty
151
+ end
152
+
153
+ it 'returns errors for type mismatches' do
154
+ errors = schema.validate(name: 123, age: "30")
155
+ expect(errors).to contain_exactly(
156
+ "Name must be a String",
157
+ "Age must be an Integer"
158
+ )
159
+ end
160
+
161
+ context 'with boolean fields' do
162
+ let(:schema) do
163
+ described_class.new.tap do |s|
164
+ s.boolean(:active)
165
+ end
166
+ end
167
+
168
+ it 'accepts true values' do
169
+ errors = schema.validate(active: true)
170
+ expect(errors).to be_empty
171
+ end
172
+
173
+ it 'accepts false values' do
174
+ errors = schema.validate(active: false)
175
+ expect(errors).to be_empty
176
+ end
177
+
178
+ it 'accepts nil values' do
179
+ errors = schema.validate(active: nil)
180
+ expect(errors).to be_empty
181
+ end
182
+
183
+ it 'returns errors for non-boolean values' do
184
+ errors = schema.validate(active: 'true')
185
+ expect(errors).to contain_exactly("Active must be true or false")
186
+
187
+ errors = schema.validate(active: 1)
188
+ expect(errors).to contain_exactly("Active must be true or false")
189
+ end
190
+
191
+ it 'can be required using presence validation' do
192
+ schema.validates :active, presence: true
193
+ errors = schema.validate(active: nil)
194
+ expect(errors).to contain_exactly("Active can't be blank")
195
+ end
196
+ end
197
+
198
+ context 'with string fields' do
199
+ let(:schema) do
200
+ described_class.new.tap do |s|
201
+ s.string(:name)
202
+ end
203
+ end
204
+
205
+ it 'accepts string values' do
206
+ errors = schema.validate(name: "John")
207
+ expect(errors).to be_empty
208
+ end
209
+
210
+ it 'accepts nil values' do
211
+ errors = schema.validate(name: nil)
212
+ expect(errors).to be_empty
213
+ end
214
+
215
+ it 'returns errors for non-string values' do
216
+ errors = schema.validate(name: 123)
217
+ expect(errors).to contain_exactly("Name must be a String")
218
+ end
219
+
220
+ it 'can be required using presence validation' do
221
+ schema.validates :name, presence: true
222
+ errors = schema.validate(name: nil)
223
+ expect(errors).to contain_exactly("Name can't be blank")
224
+ end
225
+ end
226
+
227
+ context 'with integer fields' do
228
+ let(:schema) do
229
+ described_class.new.tap do |s|
230
+ s.integer(:age)
231
+ end
232
+ end
233
+
234
+ it 'accepts integer values' do
235
+ errors = schema.validate(age: 30)
236
+ expect(errors).to be_empty
237
+ end
238
+
239
+ it 'accepts nil values' do
240
+ errors = schema.validate(age: nil)
241
+ expect(errors).to be_empty
242
+ end
243
+
244
+ it 'returns errors for non-integer values' do
245
+ errors = schema.validate(age: "30")
246
+ expect(errors).to contain_exactly("Age must be an Integer")
247
+ end
248
+
249
+ it 'can be required using presence validation' do
250
+ schema.validates :age, presence: true
251
+ errors = schema.validate(age: nil)
252
+ expect(errors).to contain_exactly("Age can't be blank")
253
+ end
254
+ end
255
+ end
256
+
257
+ describe 'ActiveModel validations' do
258
+ let(:schema) do
259
+ described_class.new.tap do |s|
260
+ s.string(:name)
261
+ s.integer(:age)
262
+
263
+ s.validates :name, presence: true
264
+ s.validates :age, numericality: { greater_than: 0 }
265
+ end
266
+ end
267
+
268
+ it 'generates a class that includes ActiveModel::Validations' do
269
+ schema_model = schema.schema_model
270
+ expect(schema_model.ancestors).to include(ActiveModel::Validations)
271
+ end
272
+
273
+ it 'adds accessors for defined fields' do
274
+ schema_model = schema.schema_model
275
+ instance = schema_model.new
276
+ instance.name = "John"
277
+ instance.age = 30
278
+ expect(instance.name).to eq("John")
279
+ expect(instance.age).to eq(30)
280
+ end
281
+
282
+ it 'validates presence' do
283
+ schema_model = schema.schema_model
284
+ instance = schema_model.new
285
+ expect(instance.valid?).to be false
286
+ expect(instance.errors.full_messages).to include("Name can't be blank")
287
+ end
288
+
289
+ it 'validates numericality' do
290
+ schema_model = schema.schema_model
291
+ instance = schema_model.new(name: "John", age: 0)
292
+ expect(instance.valid?).to be false
293
+ expect(instance.errors.full_messages).to include("Age must be greater than 0")
294
+ end
295
+
296
+ it 'returns true for valid instances' do
297
+ schema_model = schema.schema_model
298
+ instance = schema_model.new(name: "John", age: 30)
299
+ expect(instance.valid?).to be true
300
+ end
301
+ end
302
+ end