lluminary 0.2.0 → 0.2.2

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: '015093c0cffd9d6e752bfd3bd6b03b16fb3740f33f1e7b11285908a4be4f3317'
4
+ data.tar.gz: 4120b07419eee36bfe7d9b00cf2224ae35739ac21be9e31f3b45cb531e88b2f8
5
5
  SHA512:
6
- metadata.gz: 85ad7a6f048fc3bad3ca587a638da18e5026dce9a7f5068e551170c5531f26a1b5da83b6fe193339dbeae1eaf087d50283d96742e5f0ace04905a9eb8abb73b1
7
- data.tar.gz: 0c501c6277d1b58114998b005b02d0a6ab10aec14ed8f21026d8c682a20c9d09692ee818cb24252c2befd88f99aa578fa4302b880d6f8ed67b05ae8b56b94b87
6
+ metadata.gz: d7424d0cdfbace6684373aa11dbc3d73d5ae2e4b7dddcb2f28eff13d5ac07e5a69672fca7da7fed0ad02be92faceb45d80bc5d339906e584e5d2732584e6a0c9
7
+ data.tar.gz: e529bd7a10437c74240b8849880108268c43b6ff6312c047b0b4809b75c80d0e800cbe2d7dc51b7fea09bdf6acf1e4af716defdd9937bf81cce8eb1b4b3bb63b
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require_relative "../base"
4
+
5
+ module Lluminary
6
+ module Models
7
+ module Anthropic
8
+ class Claude35Sonnet < Lluminary::Models::Base
9
+ NAME = "claude-3-5-sonnet-latest"
10
+
11
+ def compatible_with?(provider_name)
12
+ provider_name == :anthropic
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anthropic"
4
+ require "json"
5
+ require_relative "../provider_error"
6
+
7
+ module Lluminary
8
+ module Providers
9
+ # Provider for Anthropic's models.
10
+ # Implements the Base provider interface for Anthropic's API.
11
+ class Anthropic < Base
12
+ NAME = :anthropic
13
+ DEFAULT_MODEL = Models::Anthropic::Claude35Sonnet
14
+
15
+ attr_reader :client, :config
16
+
17
+ def initialize(**config_overrides)
18
+ super
19
+ @config = { model: DEFAULT_MODEL }.merge(config)
20
+ @client = ::Anthropic::Client.new(api_key: config[:api_key])
21
+ end
22
+
23
+ def call(prompt, _task)
24
+ message =
25
+ client.messages.create(
26
+ max_tokens: 1024, # TODO: make this configurable
27
+ messages: [{ role: "user", content: prompt }],
28
+ model: model.class::NAME
29
+ )
30
+
31
+ content = message.content.first.text
32
+
33
+ {
34
+ raw: content,
35
+ parsed:
36
+ begin
37
+ JSON.parse(content) if content
38
+ rescue JSON::ParserError
39
+ nil
40
+ end
41
+ }
42
+ end
43
+
44
+ def model
45
+ @model ||= config[:model].new
46
+ end
47
+
48
+ def models
49
+ response = @client.models.list
50
+ response.data.map { |model| model.id }
51
+ end
52
+ end
53
+ end
54
+ 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)
@@ -34,6 +34,9 @@ module Lluminary
34
34
  when :bedrock
35
35
  require_relative "providers/bedrock"
36
36
  Providers::Bedrock
37
+ when :anthropic
38
+ require_relative "providers/anthropic"
39
+ Providers::Anthropic
37
40
  else
38
41
  raise ArgumentError, "Unknown provider: #{provider_name}"
39
42
  end
@@ -74,12 +77,23 @@ module Lluminary
74
77
  def output_schema_model
75
78
  @output_schema&.schema_model || Schema.new.schema_model
76
79
  end
80
+
81
+ def output_custom_validations
82
+ @output_schema&.custom_validations || []
83
+ end
84
+
85
+ def input_custom_validations
86
+ @input_schema&.custom_validations || []
87
+ end
77
88
  end
78
89
 
79
90
  attr_reader :input, :output, :parsed_response
91
+ attr_accessor :validation_failed
80
92
 
81
93
  def initialize(input = {})
82
94
  @input = self.class.input_schema_model.new(input)
95
+ @input.task_instance = self
96
+ @validation_failed = false
83
97
  define_input_methods
84
98
  end
85
99
 
@@ -120,8 +134,30 @@ module Lluminary
120
134
  raise NotImplementedError, "Subclasses must implement task_prompt"
121
135
  end
122
136
 
137
+ # Helper for validation methods to add errors
138
+ def errors
139
+ # Points to the current model being validated - used by custom validation methods
140
+ if @current_model == :output && @output
141
+ @output.errors
142
+ else
143
+ @input.errors
144
+ end
145
+ end
146
+
123
147
  private
124
148
 
149
+ def define_output_accessor_methods
150
+ return unless @output
151
+
152
+ # Define accessor methods for each output field
153
+ @output.attributes.each_key do |name|
154
+ next if name == "raw_response"
155
+ singleton_class.class_eval do
156
+ define_method(name) { @output.attributes[name.to_s] }
157
+ end
158
+ end
159
+ end
160
+
125
161
  def validate_input
126
162
  validate_input!
127
163
  end
@@ -129,6 +165,7 @@ module Lluminary
129
165
  def process_response(response)
130
166
  @parsed_response = response[:parsed]
131
167
  @output = self.class.output_schema_model.new
168
+ @output.task_instance = self
132
169
  @output.raw_response = response[:raw]
133
170
 
134
171
  # Merge the parsed response first, then validate
@@ -160,6 +197,9 @@ module Lluminary
160
197
  @output.attributes.merge!(converted_response)
161
198
  end
162
199
 
200
+ # Define methods to access output attributes directly in validation methods
201
+ define_output_accessor_methods
202
+
163
203
  # Validate after merging
164
204
  @output.valid?
165
205
 
data/lib/lluminary.rb CHANGED
@@ -3,10 +3,16 @@
3
3
  require_relative "lluminary/version"
4
4
  require_relative "lluminary/result"
5
5
  require_relative "lluminary/task"
6
- # automatically require all providers
7
- Dir[File.join(__dir__, "lluminary/providers/*.rb")].each { |file| require file }
8
- # automatically require all models
6
+ # require base model first
7
+ require_relative "lluminary/models/base"
8
+ # automatically require all models first
9
9
  Dir[File.join(__dir__, "lluminary/models/**/*.rb")].each { |file| require file }
10
+ # require base provider first
11
+ require_relative "lluminary/providers/base"
12
+ # then require all other providers
13
+ Dir[File.join(__dir__, "lluminary/providers/*.rb")].each do |file|
14
+ require file unless file.end_with?("base.rb")
15
+ end
10
16
  require_relative "lluminary/config"
11
17
 
12
18
  # Lluminary is a framework for building and running LLM-powered tasks.
@@ -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
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+ require "lluminary/providers/anthropic"
4
+
5
+ RSpec.describe Lluminary::Providers::Anthropic do
6
+ let(:config) { { api_key: "test-key" } }
7
+ let(:provider) { described_class.new(**config) }
8
+
9
+ describe "#client" do
10
+ it "returns the Anthropic client instance" do
11
+ expect(provider.client).to be_a(Anthropic::Client)
12
+ end
13
+ end
14
+
15
+ describe "#models" do
16
+ let(:mock_models_response) do
17
+ mock_model_info_1 = double("ModelInfo", id: "claude-3-5-sonnet-latest")
18
+ mock_model_info_2 = double("ModelInfo", id: "claude-3-haiku-20240307")
19
+
20
+ double(
21
+ "Page",
22
+ data: [mock_model_info_1, mock_model_info_2],
23
+ has_more: false,
24
+ first_id: "claude-3-5-sonnet-latest",
25
+ last_id: "claude-3-haiku-20240307"
26
+ )
27
+ end
28
+
29
+ before do
30
+ models_client = double("ModelsClient")
31
+ allow_any_instance_of(Anthropic::Client).to receive(:models).and_return(
32
+ models_client
33
+ )
34
+ allow(models_client).to receive(:list).and_return(mock_models_response)
35
+ end
36
+
37
+ it "returns an array of model IDs as strings" do
38
+ expect(provider.models).to eq(
39
+ %w[claude-3-5-sonnet-latest claude-3-haiku-20240307]
40
+ )
41
+ end
42
+ end
43
+
44
+ describe "#call" do
45
+ let(:prompt) { "Test prompt" }
46
+ let(:task) { "Test task" }
47
+ let(:mock_response) do
48
+ OpenStruct.new(
49
+ content: [OpenStruct.new(text: '{"summary": "Test response"}')]
50
+ )
51
+ end
52
+
53
+ before do
54
+ messages_client = double("MessagesClient")
55
+ allow_any_instance_of(Anthropic::Client).to receive(:messages).and_return(
56
+ messages_client
57
+ )
58
+ allow(messages_client).to receive(:create).and_return(mock_response)
59
+ end
60
+
61
+ it "returns a hash with raw and parsed response" do
62
+ response = provider.call(prompt, task)
63
+ expect(response).to eq(
64
+ {
65
+ raw: '{"summary": "Test response"}',
66
+ parsed: {
67
+ "summary" => "Test response"
68
+ }
69
+ }
70
+ )
71
+ end
72
+
73
+ context "when the response is not valid JSON" do
74
+ let(:mock_response) do
75
+ OpenStruct.new(content: [OpenStruct.new(text: "not valid json")])
76
+ end
77
+
78
+ it "returns raw response with nil parsed value" do
79
+ response = provider.call(prompt, task)
80
+ expect(response).to eq({ raw: "not valid json", parsed: nil })
81
+ end
82
+ end
83
+ end
84
+
85
+ describe "#model" do
86
+ it "returns the default model when not specified" do
87
+ expect(provider.model).to be_a(
88
+ Lluminary::Models::Anthropic::Claude35Sonnet
89
+ )
90
+ end
91
+
92
+ it "returns the specified model when provided in config" do
93
+ model_class = double("ModelClass")
94
+ model_instance = double("ModelInstance")
95
+
96
+ allow(model_class).to receive(:new).and_return(model_instance)
97
+
98
+ custom_provider =
99
+ described_class.new(model: model_class, api_key: "test-key")
100
+
101
+ expect(custom_provider.model).to eq(model_instance)
102
+ end
103
+ end
104
+ end