lluminary 0.2.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20a5892da2184571fc6e1a681fc5ed81143c87a5abafc745249b0a28d5ef4f3a
4
- data.tar.gz: 984b6467b9ac1a82af4107efbd72a4ae14ab05cae8e3838120dd0252ea0a5dc0
3
+ metadata.gz: cacc0edbbc80b69f4c95d6b9888707651dffc5189432920b1c814abed095ea01
4
+ data.tar.gz: 647f143f0be6053b7ca80868e2d6bd5058cfee8fae0d0e12a51ad59f8e8d5233
5
5
  SHA512:
6
- metadata.gz: 85ad7a6f048fc3bad3ca587a638da18e5026dce9a7f5068e551170c5531f26a1b5da83b6fe193339dbeae1eaf087d50283d96742e5f0ace04905a9eb8abb73b1
7
- data.tar.gz: 0c501c6277d1b58114998b005b02d0a6ab10aec14ed8f21026d8c682a20c9d09692ee818cb24252c2befd88f99aa578fa4302b880d6f8ed67b05ae8b56b94b87
6
+ metadata.gz: 63dce0789e7b175ad324d7de8f7bbd4c90da575e314f14cbd3c4ab9c6b3cab5b942cd279a15b545ac322f267bda221efd3988c6b7e9c7f2f930f1de0c1af87ac
7
+ data.tar.gz: 30a523b37b850f6603e466e05c49ceb8eeb03fb8ee81c3b237b97b175ace6082e0e97ace99b210e2807fb1d9feab2520a4b95753d5690e7fb46d02df1faa0389
@@ -27,6 +27,8 @@ module Lluminary
27
27
 
28
28
  #{format_fields_descriptions(task.class.output_fields)}
29
29
 
30
+ #{format_additional_validations(task.class.output_custom_validations)}
31
+
30
32
  #{json_preamble}
31
33
 
32
34
  #{generate_example_json_object(task.class.output_fields)}
@@ -330,6 +332,15 @@ module Lluminary
330
332
  hash[subname] = generate_example_value(subname, subfield)
331
333
  end
332
334
  end
335
+
336
+ def format_additional_validations(custom_validations)
337
+ descriptions = custom_validations.map { |v| v[:description] }.compact
338
+ return "" if descriptions.empty?
339
+
340
+ section = ["Additional Validations:"]
341
+ descriptions.each { |desc| section << "- #{desc}" }
342
+ "#{section.join("\n")}\n"
343
+ end
333
344
  end
334
345
  end
335
346
  end
@@ -9,6 +9,7 @@ module Lluminary
9
9
  def initialize
10
10
  @fields = {}
11
11
  @validations = []
12
+ @custom_validations = []
12
13
  end
13
14
 
14
15
  def string(name, description: nil)
@@ -57,7 +58,7 @@ module Lluminary
57
58
  }
58
59
  end
59
60
 
60
- attr_reader :fields
61
+ attr_reader :fields, :custom_validations
61
62
 
62
63
  def validates(*args, **options)
63
64
  @validations << [args, options]
@@ -72,18 +73,21 @@ module Lluminary
72
73
  end
73
74
  end
74
75
 
76
+ def validate(method_name, description: nil)
77
+ @custom_validations << { method: method_name, description: description }
78
+ end
79
+
75
80
  def validations_for(field_name)
76
81
  @validations.select { |args, _| args.include?(field_name) }
77
82
  end
78
83
 
79
84
  def schema_model
80
85
  @schema_model ||=
81
- SchemaModel.build(fields: @fields, validations: @validations)
82
- end
83
-
84
- def validate(values)
85
- instance = schema_model.new(values)
86
- instance.valid? ? [] : instance.errors.full_messages
86
+ SchemaModel.build(
87
+ fields: @fields,
88
+ validations: @validations,
89
+ custom_validations: @custom_validations
90
+ )
87
91
  end
88
92
 
89
93
  # Internal class for defining array element types
@@ -8,6 +8,7 @@ module Lluminary
8
8
  include ActiveModel::Validations
9
9
 
10
10
  attr_reader :attributes
11
+ attr_accessor :task_instance
11
12
 
12
13
  def initialize(attributes = {})
13
14
  @attributes = attributes.transform_keys(&:to_s)
@@ -19,13 +20,14 @@ module Lluminary
19
20
  "#<#{self.class.name} #{attrs.inspect}>"
20
21
  end
21
22
 
22
- def self.build(fields:, validations:)
23
+ def self.build(fields:, validations:, custom_validations: [])
23
24
  Class.new(self) do
24
25
  class << self
25
- attr_accessor :schema_fields
26
+ attr_accessor :schema_fields, :custom_validation_methods
26
27
  end
27
28
 
28
29
  self.schema_fields = fields
30
+ self.custom_validation_methods = custom_validations
29
31
 
30
32
  # Add accessors for each field
31
33
  fields.each_key do |name|
@@ -39,7 +41,19 @@ module Lluminary
39
41
  @attributes["raw_response"] = value
40
42
  end
41
43
 
44
+ # Add custom validation hook
42
45
  validate do |record|
46
+ # Run custom validations from the task if present
47
+ if record.task_instance &&
48
+ !record.class.custom_validation_methods.empty?
49
+ record.class.custom_validation_methods.each do |validation|
50
+ method_name = validation[:method]
51
+ if record.task_instance.respond_to?(method_name)
52
+ record.task_instance.send(method_name)
53
+ end
54
+ end
55
+ end
56
+
43
57
  if record.raw_response
44
58
  begin
45
59
  JSON.parse(record.raw_response)
@@ -74,12 +74,23 @@ module Lluminary
74
74
  def output_schema_model
75
75
  @output_schema&.schema_model || Schema.new.schema_model
76
76
  end
77
+
78
+ def output_custom_validations
79
+ @output_schema&.custom_validations || []
80
+ end
81
+
82
+ def input_custom_validations
83
+ @input_schema&.custom_validations || []
84
+ end
77
85
  end
78
86
 
79
87
  attr_reader :input, :output, :parsed_response
88
+ attr_accessor :validation_failed
80
89
 
81
90
  def initialize(input = {})
82
91
  @input = self.class.input_schema_model.new(input)
92
+ @input.task_instance = self
93
+ @validation_failed = false
83
94
  define_input_methods
84
95
  end
85
96
 
@@ -120,8 +131,30 @@ module Lluminary
120
131
  raise NotImplementedError, "Subclasses must implement task_prompt"
121
132
  end
122
133
 
134
+ # Helper for validation methods to add errors
135
+ def errors
136
+ # Points to the current model being validated - used by custom validation methods
137
+ if @current_model == :output && @output
138
+ @output.errors
139
+ else
140
+ @input.errors
141
+ end
142
+ end
143
+
123
144
  private
124
145
 
146
+ def define_output_accessor_methods
147
+ return unless @output
148
+
149
+ # Define accessor methods for each output field
150
+ @output.attributes.each_key do |name|
151
+ next if name == "raw_response"
152
+ singleton_class.class_eval do
153
+ define_method(name) { @output.attributes[name.to_s] }
154
+ end
155
+ end
156
+ end
157
+
125
158
  def validate_input
126
159
  validate_input!
127
160
  end
@@ -129,6 +162,7 @@ module Lluminary
129
162
  def process_response(response)
130
163
  @parsed_response = response[:parsed]
131
164
  @output = self.class.output_schema_model.new
165
+ @output.task_instance = self
132
166
  @output.raw_response = response[:raw]
133
167
 
134
168
  # Merge the parsed response first, then validate
@@ -160,6 +194,9 @@ module Lluminary
160
194
  @output.attributes.merge!(converted_response)
161
195
  end
162
196
 
197
+ # Define methods to access output attributes directly in validation methods
198
+ define_output_accessor_methods
199
+
163
200
  # Validate after merging
164
201
  @output.valid?
165
202
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lluminary
4
+ module Tasks
5
+ class DescribeOpenAiModel < Lluminary::Task
6
+ use_provider :openai
7
+
8
+ input_schema do
9
+ string :model, description: "The OpenAI model to describe"
10
+ end
11
+
12
+ # {
13
+ # "id": "gpt-4o-2024-11-20",
14
+ # "family": "gpt-4o",
15
+ # "variant": "standard",
16
+ # "release_date": "2024-11-20",
17
+ # "status": "GA",
18
+ # "inputs": {"text": true, "image": true, "audio": false},
19
+ # "outputs": {"text": true, "audio": false}
20
+ # }
21
+
22
+ output_schema do
23
+ hash :model_description, description: "The description of the model" do
24
+ string :id,
25
+ description:
26
+ "The full OpenAI API model ID being described. EG: 'gpt-4o-2024-11-20'"
27
+ string :family,
28
+ description:
29
+ "The OpenAI model family. EG: 'gpt-4o' or 'gpt-4.1-mini'"
30
+ string :variant, description: "The OpenAI model variant"
31
+ string :release_date,
32
+ description: "The model's release date, if known."
33
+ string :status,
34
+ description: "The OpenAI model status. EG: GA or preview"
35
+ hash :inputs, description: "The model's inputs" do
36
+ boolean :text, description: "Whether the model can process text"
37
+ boolean :image, description: "Whether the model can process images"
38
+ boolean :audio, description: "Whether the model can process audio"
39
+ string :other_inputs,
40
+ description: "Other inputs the model can process"
41
+ end
42
+ hash :outputs, description: "The model's outputs" do
43
+ boolean :text, description: "Whether the model can output text"
44
+ boolean :image, description: "Whether the model can output images"
45
+ boolean :audio, description: "Whether the model can output audio"
46
+ string :other_outputs,
47
+ description: "Other outputs the model can return"
48
+ end
49
+ end
50
+ end
51
+
52
+ def task_prompt
53
+ <<~PROMPT
54
+ You are an expert in OpenAI models. You will be given a model ID and asked to describe the model using structured data.
55
+
56
+ Model ID: #{input.model}
57
+ PROMPT
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lluminary
4
+ module Tasks
5
+ class IdentifyAndDescribeOpenAiModels < Lluminary::Task
6
+ use_provider :bedrock, model: Lluminary::Models::Bedrock::AmazonNovaProV1
7
+
8
+ input_schema do
9
+ array :models, description: "List of OpenAI models" do
10
+ string
11
+ end
12
+ end
13
+
14
+ output_schema do
15
+ array :root_models,
16
+ description: "List of root models and their versions" do
17
+ hash do
18
+ string :name,
19
+ description:
20
+ "The root name of the model. For example, 'gpt-4' or 'gpt-4o'"
21
+ array :versions,
22
+ description:
23
+ "List of versions of the root model. For example, '0125-preview' or '0613' or '2024-04-09'" do
24
+ string
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ def task_prompt
31
+ <<~PROMPT
32
+ You are an expert in OpenAI models. You will be given a list of OpenAI models and asked to group them together by the "root" model type and capability and list the various versions of the root model.
33
+
34
+ Keep in mind that some "root" models have names with the same root name but different capabilities. For example, "gpt-4o" and "gpt-4o-audio" are distinct models, since they have different capabilities and each has their own versions.
35
+
36
+ "gpt-4.5-preview" and "gpt-4.5-preview-2025-02-27" are examples of the "gpt-4.5" root model. There are two versions of the "gpt-4.5" root model: "preview" and "preview-2025-02-27".
37
+
38
+ Given the following list of models, please group them together by the "root" model type and list their versions.
39
+
40
+ Your response will be used to generate code that will make use of the models and their verisons.
41
+
42
+ It's critical that you represent every model and version from the following list in your response. Any model or version that is missed will be excluded from subsequent code generation and that will make them very, very sad. We don't want any sad models.
43
+
44
+ DO NOT include any other models or versions in your response other than those from ones listed below. Use your expertise in OpenAI models to distinguish between different "root" models and their versions.
45
+
46
+ Models: #{models.join(", ")}
47
+ PROMPT
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1035,6 +1035,38 @@ RSpec.describe Lluminary::Models::Base do
1035
1035
  end
1036
1036
  end
1037
1037
 
1038
+ context "with custom validation descriptions" do
1039
+ before do
1040
+ task_class.output_schema do
1041
+ string :name, description: "The person's name"
1042
+ integer :confidence, description: "Confidence score from 0-100"
1043
+ validate :validate_confidence_score,
1044
+ description: "Confidence score must be between 0 and 100"
1045
+ validate :validate_other_thing, description: nil
1046
+ end
1047
+ end
1048
+
1049
+ it "includes an Additional Validations section with non-nil descriptions" do
1050
+ prompt = model.format_prompt(task)
1051
+ expect(prompt).to include("Additional Validations:")
1052
+ expect(prompt).to include(
1053
+ "- Confidence score must be between 0 and 100"
1054
+ )
1055
+ expect(prompt).not_to include("- \n") # Should not include a blank bullet for nil
1056
+ end
1057
+
1058
+ it "omits Additional Validations section if all descriptions are nil" do
1059
+ # Redefine schema with only nil descriptions
1060
+ task_class.output_schema do
1061
+ string :name, description: "The person's name"
1062
+ validate :validate_confidence_score, description: nil
1063
+ validate :validate_other_thing, description: nil
1064
+ end
1065
+ prompt = model.format_prompt(task)
1066
+ expect(prompt).not_to include("Additional Validations:")
1067
+ end
1068
+ end
1069
+
1038
1070
  context "JSON example generation" do
1039
1071
  context "with simple field types" do
1040
1072
  it "generates correct JSON example for string field" do
@@ -169,4 +169,263 @@ RSpec.describe Lluminary::SchemaModel do
169
169
  )
170
170
  end
171
171
  end
172
+
173
+ describe "boolean field validation" do
174
+ let(:fields) do
175
+ { active: { type: :boolean, description: "Whether the item is active" } }
176
+ end
177
+ let(:model_class) { described_class.build(fields: fields, validations: []) }
178
+
179
+ it "accepts true values" do
180
+ instance = model_class.new(active: true)
181
+ expect(instance.valid?).to be true
182
+ expect(instance.errors.full_messages).to be_empty
183
+ end
184
+
185
+ it "accepts false values" do
186
+ instance = model_class.new(active: false)
187
+ expect(instance.valid?).to be true
188
+ expect(instance.errors.full_messages).to be_empty
189
+ end
190
+
191
+ it "accepts nil values" do
192
+ instance = model_class.new(active: nil)
193
+ expect(instance.valid?).to be true
194
+ expect(instance.errors.full_messages).to be_empty
195
+ end
196
+
197
+ it "returns errors for non-boolean values" do
198
+ instance = model_class.new(active: "true")
199
+ expect(instance.valid?).to be false
200
+ expect(instance.errors.full_messages).to contain_exactly(
201
+ "Active must be true or false"
202
+ )
203
+
204
+ instance = model_class.new(active: 1)
205
+ expect(instance.valid?).to be false
206
+ expect(instance.errors.full_messages).to contain_exactly(
207
+ "Active must be true or false"
208
+ )
209
+ end
210
+
211
+ it "can be required using presence validation" do
212
+ validations = [[[:active], { presence: true }]]
213
+ model_class_with_presence =
214
+ described_class.build(fields: fields, validations: validations)
215
+ instance = model_class_with_presence.new(active: nil)
216
+ expect(instance.valid?).to be false
217
+ expect(instance.errors.full_messages).to contain_exactly(
218
+ "Active can't be blank"
219
+ )
220
+ end
221
+ end
222
+
223
+ describe "hash field with array validation" do
224
+ let(:fields) do
225
+ {
226
+ config: {
227
+ type: :hash,
228
+ description: "Configuration",
229
+ fields: {
230
+ name: {
231
+ type: :string,
232
+ description: nil
233
+ },
234
+ tags: {
235
+ type: :array,
236
+ description: nil,
237
+ element_type: {
238
+ type: :string,
239
+ description: nil
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+ end
246
+ let(:model_class) { described_class.build(fields: fields, validations: []) }
247
+
248
+ it "validates arrays inside hashes" do
249
+ instance =
250
+ model_class.new(
251
+ config: {
252
+ name: "test",
253
+ tags: ["valid", 123, "also valid"]
254
+ }
255
+ )
256
+ expect(instance.valid?).to be false
257
+ expect(instance.errors.full_messages).to contain_exactly(
258
+ "Config[tags][1] must be a String"
259
+ )
260
+ end
261
+ end
262
+
263
+ describe "nested hash validation" do
264
+ let(:fields) do
265
+ {
266
+ config: {
267
+ type: :hash,
268
+ description: nil,
269
+ fields: {
270
+ name: {
271
+ type: :string,
272
+ description: nil
273
+ },
274
+ database: {
275
+ type: :hash,
276
+ description: nil,
277
+ fields: {
278
+ host: {
279
+ type: :string,
280
+ description: nil
281
+ },
282
+ port: {
283
+ type: :integer,
284
+ description: nil
285
+ },
286
+ credentials: {
287
+ type: :hash,
288
+ description: nil,
289
+ fields: {
290
+ username: {
291
+ type: :string,
292
+ description: nil
293
+ },
294
+ password: {
295
+ type: :string,
296
+ description: nil
297
+ }
298
+ }
299
+ }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ end
306
+ let(:model_class) { described_class.build(fields: fields, validations: []) }
307
+
308
+ it "validates nested hashes" do
309
+ instance =
310
+ model_class.new(
311
+ config: {
312
+ name: "test",
313
+ database: {
314
+ host: 123, # should be string
315
+ port: "80", # should be integer
316
+ credentials: {
317
+ username: 456, # should be string
318
+ password: 789 # should be string
319
+ }
320
+ }
321
+ }
322
+ )
323
+ expect(instance.valid?).to be false
324
+ expect(instance.errors.full_messages).to contain_exactly(
325
+ "Config[database][host] must be a String",
326
+ "Config[database][port] must be an Integer",
327
+ "Config[database][credentials][username] must be a String",
328
+ "Config[database][credentials][password] must be a String"
329
+ )
330
+ end
331
+ end
332
+
333
+ describe "hash type enforcement" do
334
+ let(:fields) do
335
+ {
336
+ config: {
337
+ type: :hash,
338
+ description: nil,
339
+ fields: {
340
+ host: {
341
+ type: :string,
342
+ description: nil
343
+ }
344
+ }
345
+ }
346
+ }
347
+ end
348
+ let(:model_class) { described_class.build(fields: fields, validations: []) }
349
+
350
+ it "validates that value is a hash" do
351
+ instance = model_class.new(config: "not a hash")
352
+ expect(instance.valid?).to be false
353
+ expect(instance.errors.full_messages).to contain_exactly(
354
+ "Config must be a Hash"
355
+ )
356
+ end
357
+ end
358
+
359
+ describe "array of hashes validation" do
360
+ let(:fields) do
361
+ {
362
+ users: {
363
+ type: :array,
364
+ description: nil,
365
+ element_type: {
366
+ type: :hash,
367
+ description: nil,
368
+ fields: {
369
+ name: {
370
+ type: :string,
371
+ description: nil
372
+ },
373
+ age: {
374
+ type: :integer,
375
+ description: nil
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+ end
382
+ let(:model_class) { described_class.build(fields: fields, validations: []) }
383
+
384
+ it "validates hashes inside arrays" do
385
+ instance =
386
+ model_class.new(
387
+ users: [
388
+ { name: "Alice", age: 30 },
389
+ { name: 123, age: "invalid" }, # name should be string, age should be integer
390
+ { name: "Bob", age: 25 }
391
+ ]
392
+ )
393
+ expect(instance.valid?).to be false
394
+ expect(instance.errors.full_messages).to contain_exactly(
395
+ "Users[1][name] must be a String",
396
+ "Users[1][age] must be an Integer"
397
+ )
398
+ end
399
+ end
400
+
401
+ describe "float field validation" do
402
+ let(:fields) { { score: { type: :float, description: "The score" } } }
403
+ let(:model_class) { described_class.build(fields: fields, validations: []) }
404
+
405
+ it "accepts float values" do
406
+ instance = model_class.new(score: 3.14)
407
+ expect(instance.valid?).to be true
408
+ expect(instance.errors.full_messages).to be_empty
409
+ end
410
+
411
+ it "accepts nil values" do
412
+ instance = model_class.new(score: nil)
413
+ expect(instance.valid?).to be true
414
+ expect(instance.errors.full_messages).to be_empty
415
+ end
416
+
417
+ it "returns errors for non-float values" do
418
+ instance = model_class.new(score: "not a float")
419
+ expect(instance.valid?).to be false
420
+ expect(instance.errors.full_messages).to contain_exactly(
421
+ "Score must be a float"
422
+ )
423
+
424
+ instance = model_class.new(score: 42)
425
+ expect(instance.valid?).to be false
426
+ expect(instance.errors.full_messages).to contain_exactly(
427
+ "Score must be a float"
428
+ )
429
+ end
430
+ end
172
431
  end
@@ -10,6 +10,58 @@ RSpec.describe Lluminary::Schema do
10
10
  end
11
11
  end
12
12
 
13
+ describe "#validate" do
14
+ it "registers a custom validation method" do
15
+ schema.validate(:validate_something)
16
+ expect(schema.custom_validations).to eq(
17
+ [{ method: :validate_something, description: nil }]
18
+ )
19
+ end
20
+
21
+ it "can register multiple validation methods" do
22
+ schema.validate(:validate_something)
23
+ schema.validate(:validate_something_else)
24
+ expect(schema.custom_validations).to eq(
25
+ [
26
+ { method: :validate_something, description: nil },
27
+ { method: :validate_something_else, description: nil }
28
+ ]
29
+ )
30
+ end
31
+
32
+ it "registers a custom validation method with a description" do
33
+ schema.validate(
34
+ :validate_score_range,
35
+ description: "Score must be between 0 and 100"
36
+ )
37
+ expect(schema.custom_validations).to eq(
38
+ [
39
+ {
40
+ method: :validate_score_range,
41
+ description: "Score must be between 0 and 100"
42
+ }
43
+ ]
44
+ )
45
+ end
46
+
47
+ it "registers multiple custom validations with and without descriptions" do
48
+ schema.validate(
49
+ :validate_score_range,
50
+ description: "Score must be between 0 and 100"
51
+ )
52
+ schema.validate(:validate_score_parity)
53
+ expect(schema.custom_validations).to eq(
54
+ [
55
+ {
56
+ method: :validate_score_range,
57
+ description: "Score must be between 0 and 100"
58
+ },
59
+ { method: :validate_score_parity, description: nil }
60
+ ]
61
+ )
62
+ end
63
+ end
64
+
13
65
  describe "#string" do
14
66
  it "adds a string field to the schema" do
15
67
  schema.string(:name)
@@ -213,12 +265,6 @@ RSpec.describe Lluminary::Schema do
213
265
  )
214
266
  end
215
267
 
216
- it "validates array elements" do
217
- schema.array(:numbers) { integer }
218
- errors = schema.validate(numbers: [1, "2", 3])
219
- expect(errors).to contain_exactly("Numbers[1] must be an Integer")
220
- end
221
-
222
268
  it "supports hashes inside arrays" do
223
269
  schema.array(:users) do
224
270
  hash do
@@ -248,29 +294,6 @@ RSpec.describe Lluminary::Schema do
248
294
  }
249
295
  )
250
296
  end
251
-
252
- it "validates hashes inside arrays" do
253
- schema.array(:users) do
254
- hash do
255
- string :name
256
- integer :age
257
- end
258
- end
259
-
260
- errors =
261
- schema.validate(
262
- users: [
263
- { name: "Alice", age: 30 },
264
- { name: 123, age: "invalid" }, # name should be string, age should be integer
265
- { name: "Bob", age: 25 }
266
- ]
267
- )
268
-
269
- expect(errors).to contain_exactly(
270
- "Users[1][name] must be a String",
271
- "Users[1][age] must be an Integer"
272
- )
273
- end
274
297
  end
275
298
 
276
299
  describe "#hash" do
@@ -307,33 +330,6 @@ RSpec.describe Lluminary::Schema do
307
330
  )
308
331
  end
309
332
 
310
- it "validates hash values" do
311
- schema.hash(:config) do
312
- string :host
313
- integer :port
314
- end
315
-
316
- errors =
317
- schema.validate(
318
- config: {
319
- host: 123, # should be string
320
- port: "80" # should be integer
321
- }
322
- )
323
-
324
- expect(errors).to contain_exactly(
325
- "Config[host] must be a String",
326
- "Config[port] must be an Integer"
327
- )
328
- end
329
-
330
- it "validates that value is a hash" do
331
- schema.hash(:config) { string :host }
332
-
333
- errors = schema.validate(config: "not a hash")
334
- expect(errors).to contain_exactly("Config must be a Hash")
335
- end
336
-
337
333
  it "supports nested hashes" do
338
334
  schema.hash(:config) do
339
335
  string :name
@@ -389,42 +385,6 @@ RSpec.describe Lluminary::Schema do
389
385
  )
390
386
  end
391
387
 
392
- it "validates nested hashes" do
393
- schema.hash(:config) do
394
- string :name
395
- hash :database do
396
- string :host
397
- integer :port
398
- hash :credentials do
399
- string :username
400
- string :password
401
- end
402
- end
403
- end
404
-
405
- errors =
406
- schema.validate(
407
- config: {
408
- name: "test",
409
- database: {
410
- host: 123, # should be string
411
- port: "80", # should be integer
412
- credentials: {
413
- username: 456, # should be string
414
- password: 789 # should be string
415
- }
416
- }
417
- }
418
- )
419
-
420
- expect(errors).to contain_exactly(
421
- "Config[database][host] must be a String",
422
- "Config[database][port] must be an Integer",
423
- "Config[database][credentials][username] must be a String",
424
- "Config[database][credentials][password] must be a String"
425
- )
426
- end
427
-
428
388
  it "supports arrays inside hashes" do
429
389
  schema.hash(:config) do
430
390
  string :name
@@ -454,25 +414,6 @@ RSpec.describe Lluminary::Schema do
454
414
  }
455
415
  )
456
416
  end
457
-
458
- it "validates arrays inside hashes" do
459
- schema.hash(:config) do
460
- string :name
461
- array :tags do
462
- string
463
- end
464
- end
465
-
466
- errors =
467
- schema.validate(
468
- config: {
469
- name: "test",
470
- tags: ["valid", 123, "also valid"] # second element should be string
471
- }
472
- )
473
-
474
- expect(errors).to contain_exactly("Config[tags][1] must be a String")
475
- end
476
417
  end
477
418
 
478
419
  describe "primitive types" do
@@ -509,136 +450,6 @@ RSpec.describe Lluminary::Schema do
509
450
  second_call = schema.fields
510
451
  expect(first_call).to be(second_call)
511
452
  end
512
-
513
- context "with datetime fields" do
514
- let(:schema) { described_class.new.tap { |s| s.datetime(:start_time) } }
515
-
516
- it "accepts DateTime values" do
517
- errors = schema.validate(start_time: DateTime.now)
518
- expect(errors).to be_empty
519
- end
520
-
521
- it "accepts nil values" do
522
- errors = schema.validate(start_time: nil)
523
- expect(errors).to be_empty
524
- end
525
-
526
- it "returns errors for non-DateTime values" do
527
- errors = schema.validate(start_time: "2024-01-01")
528
- expect(errors).to contain_exactly("Start time must be a DateTime")
529
- end
530
-
531
- it "can be required using presence validation" do
532
- schema.validates :start_time, presence: true
533
- errors = schema.validate(start_time: nil)
534
- expect(errors).to contain_exactly("Start time can't be blank")
535
- end
536
- end
537
- end
538
-
539
- describe "#validate" do
540
- let(:schema) do
541
- described_class.new.tap do |s|
542
- s.string(:name)
543
- s.integer(:age)
544
- end
545
- end
546
-
547
- it "returns no errors when all values match their field types" do
548
- errors = schema.validate(name: "John", age: 30)
549
- expect(errors).to be_empty
550
- end
551
-
552
- it "returns errors for type mismatches" do
553
- errors = schema.validate(name: 123, age: "30")
554
- expect(errors).to contain_exactly(
555
- "Name must be a String",
556
- "Age must be an Integer"
557
- )
558
- end
559
-
560
- context "with boolean fields" do
561
- let(:schema) { described_class.new.tap { |s| s.boolean(:active) } }
562
-
563
- it "accepts true values" do
564
- errors = schema.validate(active: true)
565
- expect(errors).to be_empty
566
- end
567
-
568
- it "accepts false values" do
569
- errors = schema.validate(active: false)
570
- expect(errors).to be_empty
571
- end
572
-
573
- it "accepts nil values" do
574
- errors = schema.validate(active: nil)
575
- expect(errors).to be_empty
576
- end
577
-
578
- it "returns errors for non-boolean values" do
579
- errors = schema.validate(active: "true")
580
- expect(errors).to contain_exactly("Active must be true or false")
581
-
582
- errors = schema.validate(active: 1)
583
- expect(errors).to contain_exactly("Active must be true or false")
584
- end
585
-
586
- it "can be required using presence validation" do
587
- schema.validates :active, presence: true
588
- errors = schema.validate(active: nil)
589
- expect(errors).to contain_exactly("Active can't be blank")
590
- end
591
- end
592
-
593
- context "with string fields" do
594
- let(:schema) { described_class.new.tap { |s| s.string(:name) } }
595
-
596
- it "accepts string values" do
597
- errors = schema.validate(name: "John")
598
- expect(errors).to be_empty
599
- end
600
-
601
- it "accepts nil values" do
602
- errors = schema.validate(name: nil)
603
- expect(errors).to be_empty
604
- end
605
-
606
- it "returns errors for non-string values" do
607
- errors = schema.validate(name: 123)
608
- expect(errors).to contain_exactly("Name must be a String")
609
- end
610
-
611
- it "can be required using presence validation" do
612
- schema.validates :name, presence: true
613
- errors = schema.validate(name: nil)
614
- expect(errors).to contain_exactly("Name can't be blank")
615
- end
616
- end
617
-
618
- context "with integer fields" do
619
- let(:schema) { described_class.new.tap { |s| s.integer(:age) } }
620
-
621
- it "accepts integer values" do
622
- errors = schema.validate(age: 30)
623
- expect(errors).to be_empty
624
- end
625
-
626
- it "accepts nil values" do
627
- errors = schema.validate(age: nil)
628
- expect(errors).to be_empty
629
- end
630
-
631
- it "returns errors for non-integer values" do
632
- errors = schema.validate(age: "30")
633
- expect(errors).to contain_exactly("Age must be an Integer")
634
- end
635
-
636
- it "can be required using presence validation" do
637
- schema.validates :age, presence: true
638
- errors = schema.validate(age: nil)
639
- expect(errors).to contain_exactly("Age can't be blank")
640
- end
641
- end
642
453
  end
643
454
 
644
455
  describe "ActiveModel validations" do
@@ -688,4 +499,32 @@ RSpec.describe Lluminary::Schema do
688
499
  expect(instance.valid?).to be true
689
500
  end
690
501
  end
502
+
503
+ describe "custom method validations" do
504
+ it "passes custom validations to schema model" do
505
+ schema = described_class.new
506
+ schema.integer(:score)
507
+ schema.validate(:validate_score_range)
508
+
509
+ model_class = schema.schema_model
510
+ method_names =
511
+ model_class.custom_validation_methods.map { |v| v[:method] }
512
+ expect(method_names).to contain_exactly(:validate_score_range)
513
+ end
514
+
515
+ it "accepts multiple custom validations" do
516
+ schema = described_class.new
517
+ schema.integer(:score)
518
+ schema.validate(:validate_score_range)
519
+ schema.validate(:validate_score_parity)
520
+
521
+ model_class = schema.schema_model
522
+ method_names =
523
+ model_class.custom_validation_methods.map { |v| v[:method] }
524
+ expect(method_names).to contain_exactly(
525
+ :validate_score_range,
526
+ :validate_score_parity
527
+ )
528
+ end
529
+ end
691
530
  end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ RSpec.describe "Task custom validations" do
5
+ # Test task classes
6
+ class TaskWithInputValidation < Lluminary::Task
7
+ use_provider :test
8
+
9
+ input_schema do
10
+ string :text
11
+ integer :min_length, description: "Minimum word length to count"
12
+
13
+ validate :validate_input_min_length
14
+ end
15
+
16
+ output_schema { integer :word_count }
17
+
18
+ def validate_input_min_length
19
+ min_length_value = @input.attributes["min_length"]
20
+ if min_length_value && min_length_value <= 0
21
+ @input.errors.add(:min_length, "must be positive")
22
+ end
23
+ end
24
+
25
+ def task_prompt
26
+ "Count words in: #{text}"
27
+ end
28
+ end
29
+
30
+ class TaskWithOutputValidation < Lluminary::Task
31
+ use_provider :test
32
+
33
+ input_schema { string :text }
34
+
35
+ output_schema do
36
+ string :sentiment,
37
+ description: "Sentiment of the text (positive, negative, neutral)"
38
+ integer :confidence, description: "Confidence score from 0-100"
39
+
40
+ validate :validate_confidence_range
41
+ end
42
+
43
+ def validate_confidence_range
44
+ confidence_value = @output.attributes["confidence"]
45
+ if confidence_value && (confidence_value < 0 || confidence_value > 100)
46
+ @output.errors.add(:confidence, "must be between 0 and 100")
47
+ end
48
+ end
49
+
50
+ def task_prompt
51
+ "Analyze sentiment of: #{text}"
52
+ end
53
+ end
54
+
55
+ class TaskWithBothValidations < Lluminary::Task
56
+ use_provider :test
57
+
58
+ input_schema do
59
+ string :text
60
+ array :hashtags, description: "Hashtags to analyze" do
61
+ string
62
+ end
63
+
64
+ validate :validate_hashtags
65
+ end
66
+
67
+ output_schema do
68
+ array :relevant_hashtags do
69
+ string
70
+ end
71
+ hash :analysis do
72
+ string :top_hashtag
73
+ integer :count
74
+ end
75
+
76
+ validate :validate_top_hashtag
77
+ end
78
+
79
+ def validate_hashtags
80
+ hashtags_value = @input.attributes["hashtags"]
81
+ if hashtags_value && hashtags_value.any? &&
82
+ !hashtags_value.all? { |h| h.start_with?("#") }
83
+ @input.errors.add(:hashtags, "must all start with # symbol")
84
+ end
85
+ end
86
+
87
+ def validate_top_hashtag
88
+ top_hashtag = @output.attributes.dig("analysis", "top_hashtag")
89
+ relevant_hashtags = @output.attributes["relevant_hashtags"]
90
+ if top_hashtag && relevant_hashtags &&
91
+ !relevant_hashtags.include?(top_hashtag)
92
+ @output.errors.add(
93
+ :analysis,
94
+ "top hashtag must be in the relevant_hashtags list"
95
+ )
96
+ end
97
+ end
98
+
99
+ def task_prompt
100
+ "Analyze hashtags in: #{text}"
101
+ end
102
+ end
103
+
104
+ # Override Test provider for predictable responses
105
+ class TestProvider < Lluminary::Providers::Test
106
+ def initialize(response_data = nil)
107
+ @response_data = response_data || {}
108
+ end
109
+
110
+ def call(prompt, task)
111
+ if @response_data[task.class.name]
112
+ @response_data[task.class.name]
113
+ else
114
+ { raw: "{}", parsed: {} }
115
+ end
116
+ end
117
+ end
118
+
119
+ describe "input validations" do
120
+ before do
121
+ # Reset the provider to use our test provider
122
+ TaskWithInputValidation.provider = TestProvider.new
123
+ end
124
+
125
+ it "validates input with custom methods" do
126
+ task = TaskWithInputValidation.new(text: "Hello world", min_length: 0)
127
+ expect(task.valid?).to be false
128
+ expect(task.input.errors.full_messages).to include(
129
+ "Min length must be positive"
130
+ )
131
+ end
132
+
133
+ it "accepts valid input" do
134
+ task = TaskWithInputValidation.new(text: "Hello world", min_length: 3)
135
+ expect(task.valid?).to be true
136
+ expect(task.input.errors.full_messages).to be_empty
137
+ end
138
+
139
+ it "rejects invalid input in call" do
140
+ result = TaskWithInputValidation.call(text: "Hello world", min_length: -5)
141
+ expect(result.input.valid?).to be false
142
+ expect(result.output).to be_nil
143
+ end
144
+ end
145
+
146
+ describe "output validations" do
147
+ before do
148
+ # Setup test provider with custom responses
149
+ responses = {
150
+ "TaskWithOutputValidation" => {
151
+ raw: '{"sentiment": "positive", "confidence": 150}',
152
+ parsed: {
153
+ "sentiment" => "positive",
154
+ "confidence" => 150 # Invalid: over 100
155
+ }
156
+ }
157
+ }
158
+ TaskWithOutputValidation.provider = TestProvider.new(responses)
159
+ end
160
+
161
+ it "validates output with custom methods" do
162
+ result = TaskWithOutputValidation.call(text: "I love this product!")
163
+ expect(result.output.valid?).to be false
164
+ expect(result.output.errors.full_messages).to include(
165
+ "Confidence must be between 0 and 100"
166
+ )
167
+ end
168
+
169
+ it "works with valid output" do
170
+ # Patch the provider with valid data for this test
171
+ valid_responses = {
172
+ "TaskWithOutputValidation" => {
173
+ raw: '{"sentiment": "positive", "confidence": 95}',
174
+ parsed: {
175
+ "sentiment" => "positive",
176
+ "confidence" => 95 # Valid: between 0-100
177
+ }
178
+ }
179
+ }
180
+ TaskWithOutputValidation.provider = TestProvider.new(valid_responses)
181
+
182
+ result = TaskWithOutputValidation.call(text: "I love this product!")
183
+ expect(result.output.valid?).to be true
184
+ expect(result.output.errors.full_messages).to be_empty
185
+ expect(result.output.sentiment).to eq("positive")
186
+ expect(result.output.confidence).to eq(95)
187
+ end
188
+ end
189
+
190
+ describe "both input and output validations" do
191
+ before do
192
+ # Setup test provider with custom responses
193
+ responses = {
194
+ "TaskWithBothValidations" => {
195
+ raw:
196
+ '{"relevant_hashtags": ["#ruby", "#rails"], "analysis": {"top_hashtag": "#javascript", "count": 5}}',
197
+ parsed: {
198
+ "relevant_hashtags" => %w[#ruby #rails],
199
+ "analysis" => {
200
+ "top_hashtag" => "#javascript", # Invalid: not in relevant_hashtags
201
+ "count" => 5
202
+ }
203
+ }
204
+ }
205
+ }
206
+ TaskWithBothValidations.provider = TestProvider.new(responses)
207
+ end
208
+
209
+ it "validates input with custom methods" do
210
+ task =
211
+ TaskWithBothValidations.new(
212
+ text: "Hello world",
213
+ hashtags: %w[ruby rails]
214
+ )
215
+ expect(task.valid?).to be false
216
+ expect(task.input.errors.full_messages).to include(
217
+ "Hashtags must all start with # symbol"
218
+ )
219
+ end
220
+
221
+ it "validates output with custom methods" do
222
+ # Input is valid for this test
223
+ result =
224
+ TaskWithBothValidations.call(
225
+ text: "Hello world",
226
+ hashtags: %w[#ruby #rails]
227
+ )
228
+ expect(result.output.valid?).to be false
229
+ expect(result.output.errors.full_messages).to include(
230
+ "Analysis top hashtag must be in the relevant_hashtags list"
231
+ )
232
+ end
233
+
234
+ it "works with valid input and output" do
235
+ # Patch the provider with valid data for this test
236
+ valid_responses = {
237
+ "TaskWithBothValidations" => {
238
+ raw:
239
+ '{"relevant_hashtags": ["#ruby", "#rails"], "analysis": {"top_hashtag": "#ruby", "count": 5}}',
240
+ parsed: {
241
+ "relevant_hashtags" => %w[#ruby #rails],
242
+ "analysis" => {
243
+ "top_hashtag" => "#ruby", # Valid: in relevant_hashtags
244
+ "count" => 5
245
+ }
246
+ }
247
+ }
248
+ }
249
+ TaskWithBothValidations.provider = TestProvider.new(valid_responses)
250
+
251
+ result =
252
+ TaskWithBothValidations.call(
253
+ text: "Hello world",
254
+ hashtags: %w[#ruby #rails]
255
+ )
256
+ expect(result.input.valid?).to be true
257
+ expect(result.output.valid?).to be true
258
+ expect(result.output.relevant_hashtags).to eq(%w[#ruby #rails])
259
+ expect(result.output.analysis["top_hashtag"]).to eq("#ruby")
260
+ end
261
+ end
262
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
+ require "simplecov"
3
+ SimpleCov.start { add_filter "/spec/" } if ENV["COVERAGE"]
4
+
2
5
  require "dotenv"
3
6
  Dotenv.load
4
7
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lluminary
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Hughes
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-24 00:00:00.000000000 Z
11
+ date: 2025-05-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -170,6 +170,20 @@ dependencies:
170
170
  - - "~>"
171
171
  - !ruby/object:Gem::Version
172
172
  version: '6.2'
173
+ - !ruby/object:Gem::Dependency
174
+ name: simplecov
175
+ requirement: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: 0.22.0
180
+ type: :development
181
+ prerelease: false
182
+ version_requirements: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: 0.22.0
173
187
  description: 'Lluminary is a framework for building applications that leverage Large
174
188
  Language Models. It provides a structured way to define tasks, manage prompts, and
175
189
  handle LLM interactions.
@@ -197,6 +211,8 @@ files:
197
211
  - lib/lluminary/schema.rb
198
212
  - lib/lluminary/schema_model.rb
199
213
  - lib/lluminary/task.rb
214
+ - lib/lluminary/tasks/describe_openai_model.rb
215
+ - lib/lluminary/tasks/identify_and_describe_open_ai_models.rb
200
216
  - lib/lluminary/validation_error.rb
201
217
  - lib/lluminary/version.rb
202
218
  - spec/examples/analyze_text_spec.rb
@@ -220,6 +236,7 @@ files:
220
236
  - spec/lluminary/result_spec.rb
221
237
  - spec/lluminary/schema_model_spec.rb
222
238
  - spec/lluminary/schema_spec.rb
239
+ - spec/lluminary/task_custom_validation_spec.rb
223
240
  - spec/lluminary/task_spec.rb
224
241
  - spec/spec_helper.rb
225
242
  homepage: https://github.com/dhughes/lluminary