lluminary 0.1.4 → 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 +177 -66
- data/lib/lluminary/schema.rb +37 -7
- data/lib/lluminary/schema_model.rb +149 -65
- 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/examples/character_profiler_spec.rb +85 -0
- data/spec/lluminary/models/base_spec.rb +933 -100
- data/spec/lluminary/schema_model_spec.rb +259 -0
- data/spec/lluminary/schema_spec.rb +228 -134
- data/spec/lluminary/task_custom_validation_spec.rb +262 -0
- data/spec/spec_helper.rb +3 -0
- metadata +20 -2
@@ -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,8 +20,15 @@ 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
|
25
|
+
class << self
|
26
|
+
attr_accessor :schema_fields, :custom_validation_methods
|
27
|
+
end
|
28
|
+
|
29
|
+
self.schema_fields = fields
|
30
|
+
self.custom_validation_methods = custom_validations
|
31
|
+
|
24
32
|
# Add accessors for each field
|
25
33
|
fields.each_key do |name|
|
26
34
|
define_method(name) { @attributes[name.to_s] }
|
@@ -29,11 +37,23 @@ module Lluminary
|
|
29
37
|
|
30
38
|
# Add raw_response field and validation
|
31
39
|
define_method(:raw_response) { @attributes["raw_response"] }
|
32
|
-
define_method(
|
40
|
+
define_method("raw_response=") do |value|
|
33
41
|
@attributes["raw_response"] = value
|
34
42
|
end
|
35
43
|
|
44
|
+
# Add custom validation hook
|
36
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
|
+
|
37
57
|
if record.raw_response
|
38
58
|
begin
|
39
59
|
JSON.parse(record.raw_response)
|
@@ -41,92 +61,44 @@ module Lluminary
|
|
41
61
|
record.errors.add(:raw_response, "must be valid JSON")
|
42
62
|
end
|
43
63
|
end
|
44
|
-
end
|
45
|
-
|
46
|
-
# Add type validations
|
47
|
-
validate do |record|
|
48
|
-
def validate_array_field(
|
49
|
-
record,
|
50
|
-
name,
|
51
|
-
value,
|
52
|
-
element_type,
|
53
|
-
path = nil
|
54
|
-
)
|
55
|
-
field_name = path || name
|
56
|
-
|
57
|
-
unless value.is_a?(Array)
|
58
|
-
record.errors.add(field_name, "must be an Array")
|
59
|
-
return
|
60
|
-
end
|
61
|
-
|
62
|
-
return unless element_type # untyped array
|
63
|
-
|
64
|
-
value.each_with_index do |element, index|
|
65
|
-
current_path = "#{field_name}[#{index}]"
|
66
|
-
|
67
|
-
case element_type[:type]
|
68
|
-
when :array
|
69
|
-
validate_array_field(
|
70
|
-
record,
|
71
|
-
name,
|
72
|
-
element,
|
73
|
-
element_type[:element_type],
|
74
|
-
current_path
|
75
|
-
)
|
76
|
-
when :string
|
77
|
-
unless element.is_a?(String)
|
78
|
-
record.errors.add(current_path, "must be a String")
|
79
|
-
end
|
80
|
-
when :integer
|
81
|
-
unless element.is_a?(Integer)
|
82
|
-
record.errors.add(current_path, "must be an Integer")
|
83
|
-
end
|
84
|
-
when :boolean
|
85
|
-
unless [true, false].include?(element)
|
86
|
-
record.errors.add(current_path, "must be true or false")
|
87
|
-
end
|
88
|
-
when :float
|
89
|
-
unless element.is_a?(Float)
|
90
|
-
record.errors.add(current_path, "must be a float")
|
91
|
-
end
|
92
|
-
when :datetime
|
93
|
-
unless element.is_a?(DateTime)
|
94
|
-
record.errors.add(current_path, "must be a DateTime")
|
95
|
-
end
|
96
|
-
end
|
97
|
-
end
|
98
|
-
end
|
99
64
|
|
100
65
|
record.attributes.each do |name, value|
|
101
66
|
next if name == "raw_response"
|
102
67
|
next if value.nil?
|
103
68
|
|
104
|
-
field =
|
69
|
+
field = self.class.schema_fields[name.to_sym]
|
105
70
|
next unless field
|
106
71
|
|
107
72
|
case field[:type]
|
73
|
+
when :hash
|
74
|
+
validate_hash_field(record, name.to_s.capitalize, value, field)
|
75
|
+
when :array
|
76
|
+
validate_array_field(
|
77
|
+
record,
|
78
|
+
name.to_s.capitalize,
|
79
|
+
value,
|
80
|
+
field[:element_type]
|
81
|
+
)
|
108
82
|
when :string
|
109
83
|
unless value.is_a?(String)
|
110
|
-
record.errors.add(name, "must be a String")
|
84
|
+
record.errors.add(name.to_s.capitalize, "must be a String")
|
111
85
|
end
|
112
86
|
when :integer
|
113
87
|
unless value.is_a?(Integer)
|
114
|
-
record.errors.add(name, "must be an Integer")
|
88
|
+
record.errors.add(name.to_s.capitalize, "must be an Integer")
|
115
89
|
end
|
116
90
|
when :boolean
|
117
91
|
unless [true, false].include?(value)
|
118
|
-
record.errors.add(name, "must be true or false")
|
92
|
+
record.errors.add(name.to_s.capitalize, "must be true or false")
|
119
93
|
end
|
120
94
|
when :float
|
121
95
|
unless value.is_a?(Float)
|
122
|
-
record.errors.add(name, "must be a float")
|
96
|
+
record.errors.add(name.to_s.capitalize, "must be a float")
|
123
97
|
end
|
124
98
|
when :datetime
|
125
99
|
unless value.is_a?(DateTime)
|
126
|
-
record.errors.add(name, "must be a DateTime")
|
100
|
+
record.errors.add(name.to_s.capitalize, "must be a DateTime")
|
127
101
|
end
|
128
|
-
when :array
|
129
|
-
validate_array_field(record, name, value, field[:element_type])
|
130
102
|
end
|
131
103
|
end
|
132
104
|
end
|
@@ -138,6 +110,118 @@ module Lluminary
|
|
138
110
|
define_singleton_method(:model_name) do
|
139
111
|
ActiveModel::Name.new(self, nil, "SchemaModel")
|
140
112
|
end
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
def validate_hash_field(
|
117
|
+
record,
|
118
|
+
name,
|
119
|
+
value,
|
120
|
+
field_definition,
|
121
|
+
path = nil
|
122
|
+
)
|
123
|
+
field_name = path || name
|
124
|
+
|
125
|
+
unless value.is_a?(Hash)
|
126
|
+
record.errors.add(field_name, "must be a Hash")
|
127
|
+
return
|
128
|
+
end
|
129
|
+
|
130
|
+
field_definition[:fields].each do |key, field|
|
131
|
+
current_path = path ? "#{path}[#{key}]" : "#{field_name}[#{key}]"
|
132
|
+
# Try both string and symbol keys
|
133
|
+
field_value = value[key.to_s] || value[key.to_sym]
|
134
|
+
|
135
|
+
next if field_value.nil?
|
136
|
+
|
137
|
+
case field[:type]
|
138
|
+
when :hash
|
139
|
+
validate_hash_field(record, key, field_value, field, current_path)
|
140
|
+
when :array
|
141
|
+
validate_array_field(
|
142
|
+
record,
|
143
|
+
key,
|
144
|
+
field_value,
|
145
|
+
field[:element_type],
|
146
|
+
current_path
|
147
|
+
)
|
148
|
+
when :string
|
149
|
+
unless field_value.is_a?(String)
|
150
|
+
record.errors.add(current_path, "must be a String")
|
151
|
+
end
|
152
|
+
when :integer
|
153
|
+
unless field_value.is_a?(Integer)
|
154
|
+
record.errors.add(current_path, "must be an Integer")
|
155
|
+
end
|
156
|
+
when :boolean
|
157
|
+
unless [true, false].include?(field_value)
|
158
|
+
record.errors.add(current_path, "must be true or false")
|
159
|
+
end
|
160
|
+
when :float
|
161
|
+
unless field_value.is_a?(Float)
|
162
|
+
record.errors.add(current_path, "must be a float")
|
163
|
+
end
|
164
|
+
when :datetime
|
165
|
+
unless field_value.is_a?(DateTime)
|
166
|
+
record.errors.add(current_path, "must be a DateTime")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def validate_array_field(record, name, value, element_type, path = nil)
|
173
|
+
field_name = path || name
|
174
|
+
|
175
|
+
unless value.is_a?(Array)
|
176
|
+
record.errors.add(field_name, "must be an Array")
|
177
|
+
return
|
178
|
+
end
|
179
|
+
|
180
|
+
return unless element_type # untyped array
|
181
|
+
|
182
|
+
value.each_with_index do |element, index|
|
183
|
+
current_path = "#{field_name}[#{index}]"
|
184
|
+
|
185
|
+
case element_type[:type]
|
186
|
+
when :hash
|
187
|
+
validate_hash_field(
|
188
|
+
record,
|
189
|
+
name,
|
190
|
+
element,
|
191
|
+
element_type,
|
192
|
+
current_path
|
193
|
+
)
|
194
|
+
when :array
|
195
|
+
validate_array_field(
|
196
|
+
record,
|
197
|
+
name,
|
198
|
+
element,
|
199
|
+
element_type[:element_type],
|
200
|
+
current_path
|
201
|
+
)
|
202
|
+
when :string
|
203
|
+
unless element.is_a?(String)
|
204
|
+
record.errors.add(current_path, "must be a String")
|
205
|
+
end
|
206
|
+
when :integer
|
207
|
+
unless element.is_a?(Integer)
|
208
|
+
record.errors.add(current_path, "must be an Integer")
|
209
|
+
end
|
210
|
+
when :boolean
|
211
|
+
unless [true, false].include?(element)
|
212
|
+
record.errors.add(current_path, "must be true or false")
|
213
|
+
end
|
214
|
+
when :float
|
215
|
+
unless element.is_a?(Float)
|
216
|
+
record.errors.add(current_path, "must be a float")
|
217
|
+
end
|
218
|
+
when :datetime
|
219
|
+
unless element.is_a?(DateTime)
|
220
|
+
record.errors.add(current_path, "must be a DateTime")
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
141
225
|
end
|
142
226
|
end
|
143
227
|
end
|
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
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "spec_helper"
|
3
|
+
require_relative "../../examples/character_profiler"
|
4
|
+
|
5
|
+
RSpec.describe CharacterProfiler do
|
6
|
+
let(:sample_text) { <<~TEXT }
|
7
|
+
Eliza Montenegro was not the kind of person who made a grand entrance, despite her striking appearance.
|
8
|
+
At 5'9" with curly auburn hair that framed an angular face, she preferred tailored blazers and vintage boots that had seen better days.
|
9
|
+
|
10
|
+
Her colleagues at the research lab respected her brilliant mind but found her difficult to read. She spoke rarely in meetings,
|
11
|
+
but when she did, everyone listened. The only time she seemed to lower her guard was around Dr. Chen, her mentor of fifteen years,
|
12
|
+
or when discussing her passion project: developing affordable water filtration systems for remote villages like the one her grandmother grew up in.
|
13
|
+
TEXT
|
14
|
+
|
15
|
+
describe "input validation" do
|
16
|
+
it "accepts valid text input" do
|
17
|
+
expect { described_class.call!(text: sample_text) }.not_to raise_error
|
18
|
+
end
|
19
|
+
|
20
|
+
it "requires text to be present" do
|
21
|
+
expect do described_class.call!(text: "") end.to raise_error(
|
22
|
+
Lluminary::ValidationError
|
23
|
+
)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe "output validation" do
|
28
|
+
let(:result) { described_class.call(text: sample_text) }
|
29
|
+
|
30
|
+
it "returns a character profile hash" do
|
31
|
+
character_profile = result.output.character_profile
|
32
|
+
|
33
|
+
expect(character_profile).to be_a(Hash)
|
34
|
+
end
|
35
|
+
|
36
|
+
it "includes basic profile fields" do
|
37
|
+
profile = result.output.character_profile
|
38
|
+
|
39
|
+
expect(profile["name"]).to be_a(String)
|
40
|
+
expect(profile["personality"]).to be_a(String)
|
41
|
+
expect(profile["complexity_score"]).to be_a(Float)
|
42
|
+
end
|
43
|
+
|
44
|
+
it "includes an appearance hash with required fields" do
|
45
|
+
appearance = result.output.character_profile["appearance"]
|
46
|
+
|
47
|
+
expect(appearance).to be_a(Hash)
|
48
|
+
expect(appearance["physical_traits"]).to be_a(String)
|
49
|
+
expect(appearance["style"]).to be_a(String)
|
50
|
+
end
|
51
|
+
|
52
|
+
it "includes an array of motivations" do
|
53
|
+
motivations = result.output.character_profile["motivations"]
|
54
|
+
|
55
|
+
expect(motivations).to be_an(Array)
|
56
|
+
expect(motivations).to all(be_a(String)) unless motivations.empty?
|
57
|
+
end
|
58
|
+
|
59
|
+
it "includes a relationships hash with allies and adversaries" do
|
60
|
+
relationships = result.output.character_profile["relationships"]
|
61
|
+
|
62
|
+
expect(relationships).to be_a(Hash)
|
63
|
+
|
64
|
+
expect(relationships["allies"]).to be_an(Array)
|
65
|
+
expect(relationships["allies"]).to all(be_a(String))
|
66
|
+
|
67
|
+
expect(relationships["adversaries"]).to be_an(Array)
|
68
|
+
expect(relationships["adversaries"]).to be_empty
|
69
|
+
end
|
70
|
+
|
71
|
+
it "has a complexity score between 0 and 1" do
|
72
|
+
score = result.output.character_profile["complexity_score"]
|
73
|
+
expect(score).to be >= 0.0
|
74
|
+
expect(score).to be <= 1.0
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
describe "prompt generation" do
|
79
|
+
let(:result) { described_class.call(text: sample_text) }
|
80
|
+
|
81
|
+
it "includes the text in the prompt" do
|
82
|
+
expect(result.prompt).to include(sample_text)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|