action_ai 0.2.0 → 0.4.0

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.
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ module ModelSchema
5
+ # Extends schema inference with Active Record–specific behavior.
6
+ #
7
+ # Overrides the base inference methods to incorporate database column
8
+ # metadata (nullability, defaults, limits, decimal scale) and model-level
9
+ # features (defined enums, STI inheritance).
10
+ #
11
+ # Unlike the Active Model inference, which relies solely on validation
12
+ # declarations, this module reads the actual column schema to produce
13
+ # more precise constraints.
14
+ module Inference::ActiveRecord
15
+ include Memery
16
+
17
+ def foreign_keys = reflect_on_all_associations(:belongs_to)
18
+ .map(&:foreign_key)
19
+
20
+ def cache_columns = [
21
+ *(reflect_on_all_associations
22
+ .filter_map(&:counter_cache_column)
23
+ ),
24
+ ]
25
+
26
+ private
27
+
28
+ # Excludes DB-managed columns, since they are not typically part of the
29
+ # model’s input data.
30
+ def schema_attributes = super
31
+ .except(*[
32
+ *primary_key,
33
+ *foreign_keys.flatten,
34
+ :created_at, :updated_at,
35
+ locking_column,
36
+ *cache_columns,
37
+ ].map(&:to_sym))
38
+ .merge(stored_attributes.values.reduce(:|).to_h do |name|
39
+ [ name, {
40
+ type: :string,
41
+ description: try(:human_attribute_name, name),
42
+ required: attribute_required?(name),
43
+ default: attribute_default(name),
44
+ enum: attribute_enum(name),
45
+ const: attribute_const(name),
46
+ pattern: attribute_format(name),
47
+
48
+ **attribute_length(name),
49
+ **attribute_boundaries(name),
50
+ }.compact ]
51
+ end)
52
+ .merge(schema_reflections)
53
+
54
+ memoize def schema_reflections = nested_attributes_options
55
+ .filter_map { |name, options|
56
+ reflection = reflect_on_association name
57
+
58
+ [ name, {
59
+ type: reflection.collection? ? :array : :object,
60
+ of: reflection.klass.schema,
61
+ description: try(:human_attribute_name, name),
62
+ required: reflection_required?(name),
63
+ max_items: options[:limit],
64
+ }.compact ]
65
+ }
66
+ .to_h
67
+
68
+ # Determines whether an attribute is required by checking the column’s
69
+ # null constraint, in addition to any Active Model checks.
70
+ def attribute_required? name
71
+ super { [
72
+ !column_for_attribute(name).null,
73
+
74
+ *(yield if block_given?)
75
+ ] }
76
+ end
77
+
78
+ # Returns the default value for an attribute from the column defaults.
79
+ def attribute_default name
80
+ column_defaults[name.to_s]
81
+ end
82
+
83
+ # Infers enum values from Active Record `enum` declarations and,
84
+ # for the inheritance column, includes valid STI class names (the base
85
+ # class and all descendants).
86
+ def attribute_enum name
87
+ super { [
88
+ defined_enums[name],
89
+
90
+ ([self, *descendants]
91
+ .map(&:name) if
92
+ name.to_s == inheritance_column),
93
+
94
+ *(yield if block_given?)
95
+ ] }
96
+ end
97
+
98
+ # Infers a constant value from acceptance validators that do not allow
99
+ # nil, but only for non-nullable columns.
100
+ def attribute_const name
101
+ super { [
102
+ *attribute_validators(name, :acceptance)
103
+ &.select { it.options[:allow_nil] }
104
+ .presence
105
+ &.any?,
106
+
107
+ *(yield if block_given?)
108
+ ] unless column_for_attribute(name).null }
109
+ end
110
+
111
+ # Infers the maximum string length from the column’s database limit.
112
+ def attribute_length name
113
+ super { [
114
+ {
115
+ maximum: column_for_attribute(name).limit
116
+ },
117
+
118
+ *(yield if block_given?)
119
+ ] }
120
+ end
121
+
122
+ # For decimal columns, sets `multiple_of` based on the column scale
123
+ # (e.g., scale 2 → multiple_of 0.01), ensuring the generated schema
124
+ # rejects values with more decimal places than the column supports.
125
+ def attribute_boundaries name
126
+ super { [
127
+ {
128
+ multiple_of: column_for_attribute(name)
129
+ .then.detect { it.type == :decimal }
130
+ &.then { 10 ** (-it.scale) },
131
+ },
132
+
133
+ *(yield if block_given?)
134
+ ] }
135
+ end
136
+
137
+ def reflection_required? name
138
+ reflect_on_association(name)&.then do
139
+ case it
140
+ when ActiveRecord::Reflection::BelongsToReflection
141
+ not it.options[:optional]
142
+ when ActiveRecord::Reflection::HasOneReflection
143
+ it.options[:required] || false
144
+ else
145
+ false
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "schematist"
4
+ require "active_support/concern"
5
+ require "action_ai/schema"
6
+ require "action_ai/refinements/numeric"
7
+
8
+ module ActionAI
9
+ # = Action AI Model Schema
10
+ #
11
+ # Adds a +.schema+ class method to any model that includes
12
+ # +ActiveModel::AttributeRegistration+. The returned schema is a
13
+ # +Schematist::Schema+ instance derived from the model's attribute types,
14
+ # suitable for use with +RubyLLM::Chat#with_schema+ and
15
+ # +ActionAI::Agent#returns+.
16
+ #
17
+ # Inject this module once (e.g., via the Railtie) and every ActiveModel class
18
+ # defined afterwards will gain +.schema+ automatically:
19
+ #
20
+ # ActiveModel::API.include ActionAI::ModelSchema
21
+ #
22
+ # == Type mapping
23
+ #
24
+ # ActiveModel attribute types are mapped to JSON Schema types as follows:
25
+ #
26
+ # * +:string+, +:immutable_string+, +:text+ → +string+
27
+ # * +:integer+, +:big_integer+ → +integer+
28
+ # * +:float+, +:decimal+, +:big_decimal+ → +number+
29
+ # * +:boolean+ → +boolean+
30
+ # * +:binary+ → +string+ with +contentEncoding: "base64"+
31
+ # * +:date+, +:datetime+, +:time+ → +string+ with the corresponding
32
+ # JSON Schema +format+
33
+ #
34
+ # Other types are omitted from the schema.
35
+ module ModelSchema
36
+ extend ActiveSupport::Concern
37
+ extend Schematist::Helpers
38
+
39
+ module Inference
40
+ autoload :ActiveModel, "action_ai/model_schema/active_model"
41
+ autoload :ActiveRecord, "action_ai/model_schema/active_record"
42
+ end
43
+
44
+ TYPES = {
45
+ string: :string,
46
+ immutable_string: :string,
47
+ text: :string,
48
+ integer: :integer,
49
+ big_integer: :integer,
50
+ float: :number,
51
+ decimal: :number,
52
+ big_decimal: :number,
53
+ boolean: :boolean,
54
+ date: :string,
55
+ datetime: :string,
56
+ time: :string,
57
+ binary: :string,
58
+ }.freeze
59
+
60
+ FORMATS = {
61
+ date: "date",
62
+ datetime: "date-time",
63
+ time: "time",
64
+ }.freeze
65
+
66
+ ENCODINGS = {
67
+ binary: "base64",
68
+ }.freeze
69
+
70
+ included do
71
+ extend Inference::ActiveModel if defined? ActiveModel and self <= ActiveModel::Validations
72
+ extend Inference::ActiveRecord if defined? ActiveRecord and self <= ActiveRecord::Base
73
+ end
74
+
75
+ def self.new(...) = schema(...)
76
+
77
+ class_methods do
78
+ include Memery
79
+ using Refinements::Numeric
80
+
81
+ # Returns a cached +Schematist::Schema+ instance derived from this
82
+ # model's attribute types.
83
+ #
84
+ # Numeric constraints and enum-like metadata are inferred from model
85
+ # validations when Rails exposes them, even when the validator itself is
86
+ # not actively enforced. In particular, +multiple_of+ is accepted by the
87
+ # schema generator here for JSON Schema output, even though
88
+ # +ActiveModel::Validations::NumericalityValidator+ ignores it at runtime.
89
+ memoize def schema = find_schema || build_schema
90
+
91
+ def find_schema = Schema.for(self)
92
+ &.new
93
+
94
+ def build_schema
95
+ attributes = schema_attributes
96
+
97
+ ModelSchema.new schema_name, **{
98
+ description: try(:model_name)&.human,
99
+ } do
100
+ attributes.each do |name, options|
101
+ send options.delete(:type), name, **options
102
+ end
103
+ end
104
+ end
105
+
106
+ memoize def to_json_schema = schema.to_json_schema
107
+ .with_indifferent_access
108
+
109
+ private
110
+
111
+ def schema_name = "#{name}Schema"
112
+
113
+ memoize def schema_attributes = attribute_types
114
+ .symbolize_keys
115
+ .transform_values(&:type)
116
+ .filter_map { |name, type|
117
+ [ name, {
118
+ type: (TYPES[type] or next),
119
+ format: FORMATS[type],
120
+ content_encoding: ENCODINGS[type],
121
+ description: try(:human_attribute_name, name),
122
+ required: attribute_required?(name),
123
+ default: attribute_default(name),
124
+ enum: attribute_enum(name),
125
+ const: attribute_const(name),
126
+ pattern: attribute_format(name),
127
+
128
+ **attribute_length(name),
129
+ **attribute_boundaries(name),
130
+ }.compact ]
131
+ }
132
+ .to_h
133
+
134
+ def attribute_types = raise NotImplementedError
135
+
136
+ def attribute_required?(name) = [
137
+ *(yield if block_given?)
138
+ ].any?
139
+
140
+ def attribute_default(name) = default_instance.send name
141
+
142
+ def attribute_enum(name) = [
143
+ *(yield if block_given?)
144
+ ].compact.presence
145
+ &.map(&:to_a)
146
+ &.inject(&:&)
147
+ &.then&.detect(&:many?)
148
+
149
+ def attribute_const(name) = [
150
+ *(yield if block_given?)
151
+ ].compact.presence
152
+ &.uniq
153
+ &.sole
154
+
155
+ def attribute_format(name) = [
156
+ *(yield if block_given?)
157
+ ].compact.presence
158
+ &.then { return it.first if it.one? }
159
+ &.map { "(?=.*#{it})" }
160
+ &.join
161
+ &.then { Regexp.new "\\A#{it}" }
162
+
163
+ def attribute_length(name) = [
164
+ *(yield if block_given?)
165
+ ].map { it.values_at :minimum, :maximum }
166
+ .transpose
167
+ .map(&:compact)
168
+ .map(&:presence)
169
+ .then do |(minimums, maximums)|
170
+ {
171
+ min_length: minimums&.max,
172
+ max_length: maximums&.min,
173
+ }
174
+ end
175
+
176
+ def attribute_boundaries(name) = [
177
+ *(yield if block_given?)
178
+ ].map { it.values_at :minimum, :maximum, :greater_than, :less_than, :multiple_of }
179
+ .transpose
180
+ .map(&:compact)
181
+ .map(&:presence)
182
+ .then do |(minimums, maximums, lowers, uppers, multiples)|
183
+ {
184
+ minimum: minimums&.max,
185
+ maximum: maximums&.min,
186
+ greater_than: lowers&.max,
187
+ less_than: uppers&.min,
188
+ multiple_of: multiples&.reduce(&:lcm),
189
+ }
190
+ end
191
+
192
+ memoize def default_instance = new
193
+ end
194
+ end
195
+ end
@@ -19,6 +19,10 @@ module ActionAI
19
19
  ActiveSupport.on_load(:action_ai) { self.logger ||= Rails.logger }
20
20
  end
21
21
 
22
+ initializer "action_ai.active_model_schema", before: :eager_load! do
23
+ ActiveModel::API.include ModelSchema if defined? ActiveModel
24
+ end
25
+
22
26
  initializer "action_ai.set_configs" do |app|
23
27
  paths = app.config.paths
24
28
  options = app.config.action_ai
@@ -55,8 +59,11 @@ module ActionAI
55
59
  end
56
60
 
57
61
  initializer "action_ai.set_autoload_paths", before: :set_autoload_paths do |app|
62
+ app.config.paths.add "app/ai/agents", autoload: true, eager_load: true
63
+ app.config.paths.add "test/ai/previews", autoload: true
64
+
58
65
  options = app.config.action_ai
59
- app.config.paths["test/ai/prompts/previews"].concat(options.preview_paths)
66
+ app.config.paths["test/ai/previews"].concat(options.preview_paths)
60
67
  end
61
68
 
62
69
  config.after_initialize do |app|
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ module Refinements
5
+ module Numeric
6
+ refine ::Numeric do
7
+ def lcm other
8
+ m = [ self, other ]
9
+ .grep_v(Integer)
10
+ .map(&:rationalize)
11
+ .map(&:denominator)
12
+ .reduce(&:*)
13
+
14
+ [ self, other ]
15
+ .map { it * m }
16
+ .map(&:to_i)
17
+ .reduce(&:lcm)
18
+ .send(other.is_a?(Integer) ? :/ : :fdiv, m)
19
+ end
20
+ end
21
+
22
+ refine ::Integer do
23
+ def lcm other
24
+ return other.lcm self unless
25
+ other.is_a? Integer
26
+
27
+ super
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ module Refinements
5
+ module Regexp
6
+ refine ::Regexp do
7
+ def ~@
8
+ self.class.new "\\A(?:(?!#{source}).)*\\z", options
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "schematist"
4
+ require "magic/lookup"
5
+
6
+ module ActionAI
7
+ class Schema < Schematist::Schema
8
+ extend Magic::Lookup
9
+
10
+ class << self
11
+ def name_for object_class
12
+ object_class
13
+ .to_s
14
+ .delete_suffix('Model')
15
+ .delete_suffix('Record')
16
+ .concat('Schema')
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller/metal/strong_parameters"
4
+
5
+ module ActionAI
6
+ module StructuredOutput
7
+ def ask(*, as: @model_class, **, &)
8
+ super(*, **, &)&.tap do |message|
9
+ model_class = as or next
10
+
11
+ object = message.parsed&.then do |params|
12
+ case model_class
13
+ when Array
14
+ params.map { model_class.sole.new process_response_params(it) }
15
+ else
16
+ model_class.new process_response_params(params)
17
+ end
18
+ end
19
+
20
+ message.define_singleton_method(:object) { object }
21
+ end
22
+ end
23
+
24
+ # Declares the structured return type for this action. Configures the chat
25
+ # to request output matching the model's schema, and decorates the resulting
26
+ # +RubyLLM::Message+ with +#object+ accessor.
27
+ #
28
+ # Call +returns+ inside an action method before +ask+ (or before the
29
+ # implicit ask) to indicate which ActiveModel class the LLM should fill in:
30
+ #
31
+ # class Extractor < ApplicationAI
32
+ # def person(text)
33
+ # @text = text
34
+ # returns Person do |params|
35
+ # params.permit(:name, :age)
36
+ # end
37
+ # end
38
+ # end
39
+ #
40
+ # After execution:
41
+ #
42
+ # response = Extractor.person("Alice is 30 years old")
43
+ # response.content # => '{"name":"Alice","age":30}'
44
+ # response.parsed # => {"name" => "Alice", "age" => 30}
45
+ # response.object # => #<Person name="Alice" age=30>
46
+ #
47
+ # Pass an array with a single element to declare an array return type:
48
+ #
49
+ # class Extractor < ApplicationAI
50
+ # def people(text)
51
+ # @text = text
52
+ # returns [Person] do |params|
53
+ # params.permit(:name, :age)
54
+ # end
55
+ # end
56
+ # end
57
+ #
58
+ # After execution:
59
+ #
60
+ # response = Extractor.people("Alice is 30 and Bob is 25")
61
+ # response.content # => '[{"name":"Alice","age":30},{"name":"Bob","age":25}]'
62
+ # response.parsed # => [{"name" => "Alice", "age" => 30}, {"name" => "Bob", "age" => 25}]
63
+ # response.object # => [#<Person name="Alice" age=30>, #<Person name="Bob" age=25>]
64
+ #
65
+ # The +model_class+ must respond to +.schema+, which is automatically
66
+ # available on any model that includes +ActiveModel::Attributes+.
67
+ def returns model_class, &process_response_params
68
+ chat.with_schema case @model_class = model_class
69
+ when Array
70
+ { type: "array", items: model_class.sole.to_json_schema }
71
+ else
72
+ model_class.schema
73
+ end
74
+
75
+ @process_response_params = process_response_params
76
+ end
77
+
78
+ private
79
+
80
+ def process_response_params params
81
+ ActionController::Parameters.new(params)
82
+ .then(&(@process_response_params || :itself))
83
+ end
84
+ end
85
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAI
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/action_ai.rb CHANGED
@@ -27,6 +27,8 @@ require "abstract_controller"
27
27
  require "action_ai/version"
28
28
  require "action_ai/deprecator"
29
29
 
30
+ require "ruby_llm"
31
+
30
32
  # Common Active Support usage in Action AI
31
33
  require "active_support"
32
34
  require "active_support/rails"
@@ -41,6 +43,8 @@ module ActionAI
41
43
 
42
44
  autoload :Agent
43
45
  autoload :Callbacks
46
+ autoload :Schema
47
+ autoload :ModelSchema
44
48
  autoload :PromptHelper
45
49
  autoload :Parameterized
46
50
  autoload :Preview
@@ -49,6 +53,7 @@ module ActionAI
49
53
  autoload :TestHelper
50
54
  autoload :Interaction
51
55
  autoload :ImplicitInteraction
56
+ autoload :StructuredOutput
52
57
  autoload :ExecutionJob
53
58
  autoload :QueuedExecution
54
59
 
@@ -1,5 +1,9 @@
1
1
  <% module_namespacing do -%>
2
2
  class <%= class_name %> < ApplicationAI
3
+ # Uncomment to configure model/instructions/tools using the RubyLLM agent DSL:
4
+ # model "gpt-6.1-sol"
5
+ # instructions "You are a helpful assistant."
6
+
3
7
  <% actions.each_with_index do |action, index| -%>
4
8
  <% if index != 0 -%>
5
9
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module RubyLLM
6
+ module Protocols
7
+ # An in-memory protocol that returns the current prompt as the response.
8
+ class Echo < Protocol
9
+ def complete(messages, **)
10
+ Message.new(
11
+ role: :assistant,
12
+ model: model.id,
13
+
14
+ content: messages
15
+ .reverse_each.take_while { it.role != :assistant }.reverse
16
+ .map(&:content)
17
+ .compact_blank
18
+ .join("\n\n"),
19
+
20
+ attachments: messages
21
+ .sum([], &:attachments),
22
+ ).tap { yield it if block_given? }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
+ require "ruby_llm/protocols/echo"
4
5
 
5
6
  module RubyLLM
6
7
  module Providers
@@ -8,33 +9,16 @@ module RubyLLM
8
9
  #
9
10
  # This provider mirrors the behavior of a local fake adapter:
10
11
  # - it does not initialize any remote connections,
11
- # - it returns deterministic responses based on model-specific helpers
12
- # (for example, `echo_response` from `Test::Echo`).
12
+ # - it returns deterministic responses through `echo` protocol.
13
13
  class Test < Provider
14
- autoload :Echo, "ruby_llm/providers/test/echo"
15
-
16
- include Echo
14
+ protocol :echo, Protocols::Echo
17
15
 
18
16
  def self.local? = true
19
17
 
20
- def initialize(...)
21
- # configuration not needed
18
+ def initialize(config)
19
+ @config = config
22
20
  # skip any connections
23
21
  end
24
-
25
- def complete(messages, model:, **)
26
- Message.new(
27
- role: :assistant,
28
- model_id: model.id,
29
-
30
- **send("#{model.id}_response", messages)
31
- ).tap do |message|
32
- yield message.content if block_given?
33
- end
34
- end
35
-
36
- def list_models = [Echo]
37
- .map(&:info)
38
22
  end
39
23
  end
40
24
  end
@@ -18,7 +18,7 @@ module RubyLLM
18
18
  # RubyLLM::Tester.interactions.last.role # => :user
19
19
  # RubyLLM::Tester.interactions.last.content # => "Hello!"
20
20
  #
21
- # The test provider uses an +echo+ model and returns the prompt content back as
21
+ # The test provider uses an +echo+ protocol and returns the prompt content as
22
22
  # the assistant response, so assertions stay deterministic and offline.
23
23
  module Tester
24
24
  mattr_reader :interactions, default: []
@@ -28,7 +28,7 @@ module RubyLLM
28
28
  end
29
29
  end
30
30
 
31
- Provider.register :test, Class.new(Providers::Test) {
31
+ Protocols::Echo.prepend Module.new {
32
32
  def complete(messages, ...)
33
33
  Tester.register messages
34
34
 
@@ -36,5 +36,9 @@ module RubyLLM
36
36
  end
37
37
  }
38
38
 
39
- models.refresh!
39
+ Provider.register :test, Providers::Test
40
+
41
+ configure do |config|
42
+ config.model_registry_file = "test/models.json"
43
+ end
40
44
  end