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.
@@ -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(:raw_response=) do |value|
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 = fields[name.to_sym]
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
@@ -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