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 +4 -4
- data/lib/lluminary/models/base.rb +11 -0
- data/lib/lluminary/schema.rb +11 -7
- data/lib/lluminary/schema_model.rb +16 -2
- data/lib/lluminary/task.rb +37 -0
- data/lib/lluminary/tasks/describe_openai_model.rb +61 -0
- data/lib/lluminary/tasks/identify_and_describe_open_ai_models.rb +51 -0
- data/spec/lluminary/models/base_spec.rb +32 -0
- data/spec/lluminary/schema_model_spec.rb +259 -0
- data/spec/lluminary/schema_spec.rb +80 -241
- data/spec/lluminary/task_custom_validation_spec.rb +262 -0
- data/spec/spec_helper.rb +3 -0
- metadata +19 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cacc0edbbc80b69f4c95d6b9888707651dffc5189432920b1c814abed095ea01
|
4
|
+
data.tar.gz: 647f143f0be6053b7ca80868e2d6bd5058cfee8fae0d0e12a51ad59f8e8d5233
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/lluminary/schema.rb
CHANGED
@@ -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(
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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)
|
data/lib/lluminary/task.rb
CHANGED
@@ -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
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.
|
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
|
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
|