action_ai 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5b0c31e7ae5b9944a95f32563d3873adea7134ea32e11ebfc5dbc2a81eafec9
4
- data.tar.gz: d20750877288e1e1306a58a08f6b1aeea02985d749df8ce763262e0cc68314e6
3
+ metadata.gz: 626b0d7eb4998d7c208dc99eb8e60932a3006988176bbd784935f1e00437b485
4
+ data.tar.gz: 97512e0c8fd015af6235d61b31ed36e7030702ddaf4b436b18b462b39013bd55
5
5
  SHA512:
6
- metadata.gz: 84b92d55d7310f17e3746457cd76ecdae5dbed20aee80276b61d30824b38093d13c3f4753b69925e819dac9471bcfd5d1c1733aa46c78dd68969d0350c24a0c7
7
- data.tar.gz: 0bbec9e620ec3fa03151b8dc4d6223b8a1de5d6b18cb4731c96df73d1bd2a9fb25a862c4dc3cbe27093468376a61c90d32f6763ef95f88f9c2f78118a077934e
6
+ metadata.gz: 5721c21b4234b75cc9afce08eef6e5df89b4cb3364b75df8bbc3735c9ed3735d19fe6186d1893b678e5bfb9cddcbe15511909babd0ab7851f7663fd2c4fb1011
7
+ data.tar.gz: 025dec0b60c2b398450fe31174789df35689bef9a48a5c77627619c9df3c6fd2de62b153c968430a2135b0ca2cc9ee73e29049e502e5b34acbbd1217ba523fa4
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+
6
+ ## [0.3.0] - 2026-10-13
7
+
8
+ ### Changed
9
+
10
+ - Moved to RubyLLM 2.
11
+ - Dropped `echo` test model in favor of `echo` protocol.
12
+
13
+ ### Added
14
+
15
+ #### Structured output
16
+
17
+ - `.schema` class method on any `ActiveModel::API` model, returning a cached `Schematist::Schema` instance derived from the model’s attribute types and constraints.
18
+ - Include Active Record associations as nested schemas via objects/arrays.
19
+ Consider associations registered for _nested attributes_ only.
20
+ - Prefer custom schemas over inferred ones.
21
+ Define them as `<ModelName>Schema < ActionAI::Schema` classes under `app/schemas`.
22
+ - `#returns(ModelClass)` instance method in agent actions: configures structured output via `chat.with_schema` and decorates the resulting `RubyLLM::Message` with `#object` returning a model instance.
23
+ - Use Strong Parameters to decorate returned JSON for security reasons.
24
+ AI model’s output shouldn’t be considered a trusted input.
25
+ - `#returns([ModelClass])` array syntax: configures an array schema and `#object` returns an array of model instances.
26
+
27
+
5
28
  ## [0.2.0] - 2026-06-30
6
29
 
7
30
  ### Added
@@ -13,6 +36,7 @@ All notable changes to this project will be documented in this file.
13
36
 
14
37
  - `echo` test model to support multiple chat interactions.
15
38
 
39
+
16
40
  ## [0.1.0] - 2026-05-05
17
41
 
18
42
  Refactored from Action Mailer.
data/README.rdoc CHANGED
@@ -117,6 +117,110 @@ The Agent class has the full list of configuration options. Here's an example:
117
117
  }
118
118
 
119
119
 
120
+ == Structured output (data schemas)
121
+
122
+ Action AI can instruct the model to return structured JSON that maps directly
123
+ to an ActiveModel model. Two features cooperate to make this work:
124
+
125
+ === +.schema+ on ActiveModel models
126
+
127
+ Any model that includes +ActiveModel::API+ automatically gains a +.schema+
128
+ class method that returns a +Schematist::Schema+ instance derived from the model's
129
+ attribute types.
130
+
131
+ class Person
132
+ include ActiveModel::Model
133
+ include ActiveModel::Attributes
134
+
135
+ attribute :name, :string
136
+ attribute :age, :integer
137
+ attribute :score, :float
138
+ end
139
+
140
+ Person.schema # => a Schematist::Schema instance
141
+ Person.schema.new.to_json_schema # => {name: "Person", schema: {...}}
142
+
143
+ Supported type mappings:
144
+
145
+ * +:string+, +:immutable_string+, +:text+ → JSON +string+
146
+ * +:integer+, +:big_integer+ → JSON +integer+
147
+ * +:float+, +:decimal+, +:big_decimal+ → JSON +number+
148
+ * +:boolean+ → JSON +boolean+
149
+ * +:date+, +:datetime+, +:time+ → JSON +string+ with +format+ set to
150
+ +"date"+, +"date-time"+, or +"time"+ respectively
151
+ * +:binary+ → JSON +string+ with +contentEncoding+ set to +"base64"+
152
+
153
+ Other types are not included in the schema.
154
+
155
+ Active Record associations are included as nested schemas via +object+/+array+.
156
+ To prevent unnescessary nesting or circular dependencies, the schema will include
157
+ associations registered for _nested attributes_ (via +accepts_nested_attributes_for+) only.
158
+
159
+ === +returns+ in agent actions
160
+
161
+ Call +returns ModelClass+ inside an action method to declare the expected
162
+ return type. Action AI will:
163
+
164
+ 1. Configure the chat to request output matching the model's JSON schema
165
+ (+chat.with_schema ModelClass.schema+).
166
+ 2. Decorate the resulting +RubyLLM::Message+ with Strong Parameters.
167
+ 3. Construct a model instance accessible via +#object+.
168
+
169
+ class Extractor < ApplicationAI
170
+ def person(text)
171
+ @text = text
172
+
173
+ returns Person do |params|
174
+ params.permit(:name, :age)
175
+ end
176
+ end
177
+ end
178
+
179
+ result = Extractor.person("Alice is 30 years old")
180
+ result.content # => '{"name":"Alice","age":30}'
181
+ result.parsed # => {"name" => "Alice", "age" => 30}
182
+ result.object # => #<Person name="Alice" age=30>
183
+
184
+ Pass the class wrapped in an array to declare an array return type:
185
+
186
+ class Extractor < ApplicationAI
187
+ def people(text)
188
+ @text = text
189
+
190
+ returns [Person] do |params|
191
+ params.permit(:name, :age)
192
+ end
193
+ end
194
+ end
195
+
196
+ result = Extractor.people("Alice is 30 and Bob is 25")
197
+ result.content # => '[{"name":"Alice","age":30},{"name":"Bob","age":25}]'
198
+ result.parsed # => [{"name" => "Alice", "age" => 30}, {"name" => "Bob", "age" => 25}]
199
+ result.object # => [#<Person name="Alice" age=30>, #<Person name="Bob" age=25>]
200
+
201
+ The schema is built from the model's declared attributes and cached.
202
+
203
+ === Custom schemas
204
+
205
+ If an inferred schema is not sufficient, define a +Schematist::Schema+
206
+ subclass under +app/schemas+ with the model's full class name followed by
207
+ +Schema+. Custom schemas are looked up first; models without a matching class
208
+ continue to use the inferred schema.
209
+
210
+ # app/schemas/person_schema.rb
211
+ class PersonSchema < ActionAI::Schema
212
+ string :name
213
+ integer :age, minimum: 0
214
+ end
215
+
216
+ For namespaced models, mirror the namespace in +app/schemas+:
217
+
218
+ # app/schemas/admin/person_schema.rb
219
+ class Admin::PersonSchema < ActionAI::Schema
220
+ string :name
221
+ end
222
+
223
+
120
224
  == Download and installation
121
225
 
122
226
  The latest version of Action AI can be installed with RubyGems:
@@ -279,6 +279,7 @@ module ActionAI
279
279
  class Agent < AbstractController::Base
280
280
  include Callbacks
281
281
  include ImplicitInteraction
282
+ prepend StructuredOutput
282
283
  include QueuedExecution
283
284
  include Rescuable
284
285
  include Parameterized
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_ai/refinements/regexp"
4
+
5
+ module ActionAI
6
+ module ModelSchema
7
+ # Extends schema inference with Active Model–specific behavior.
8
+ #
9
+ # Overrides the base inference methods to extract constraint metadata
10
+ # from Active Model validation declarations (presence, inclusion,
11
+ # numericality, format, length, comparison, and acceptance).
12
+ module Inference::ActiveModel
13
+ using Refinements::Regexp
14
+
15
+ private
16
+
17
+ # Determines whether an attribute is required by checking for
18
+ # a presence validator, in addition to any other sources.
19
+ def attribute_required? name
20
+ super { [
21
+ attribute_validators(name, :presence)
22
+ .any?,
23
+
24
+ *(yield if block_given?)
25
+ ] }
26
+ end
27
+
28
+ # Infers enum values from inclusion and numericality validators
29
+ # that declare an `:in` option with multiple values.
30
+ def attribute_enum name
31
+ super { [
32
+ *[
33
+ *attribute_validators(name, :numericality),
34
+ *attribute_validators(name, :inclusion),
35
+ ].filter_map { it.options[:in] },
36
+
37
+ *(yield if block_given?)
38
+ ] }
39
+ end
40
+
41
+ # Infers a constant value from validators that restrict the
42
+ # attribute to a single value: comparison/equality, single-item
43
+ # inclusion, or acceptance validators without `allow_nil`.
44
+ def attribute_const name
45
+ super { [
46
+ *[
47
+ *attribute_validators(name, :comparison),
48
+ *attribute_validators(name, :numericality),
49
+ ].filter_map { it.options[:equal_to] },
50
+
51
+ *[
52
+ *attribute_validators(name, :inclusion),
53
+ *attribute_validators(name, :numericality),
54
+ ].filter_map { it.options[:in] }
55
+ .select(&:one?)
56
+ .map(&:first),
57
+
58
+ *attribute_validators(name, :acceptance)
59
+ .reject { it.options[:allow_nil] }
60
+ .presence
61
+ &.any?,
62
+
63
+ *(yield if block_given?)
64
+ ] }
65
+ end
66
+
67
+ # Infers a regex pattern from format validators, combining
68
+ # multiple patterns into a single expression.
69
+ # Validators with `:without` are negated.
70
+ def attribute_format name
71
+ super { [
72
+ *attribute_validators(name, :format)
73
+ .map(&:options)
74
+ .filter_map { it[:with] or ~it[:without] rescue nil },
75
+
76
+ *(yield if block_given?)
77
+ ] }
78
+ end
79
+
80
+ # Infers minimum and maximum string length from length validators.
81
+ def attribute_length name
82
+ super { [
83
+ *attribute_validators(name, :length)
84
+ .map(&:options)
85
+ .map { {
86
+ minimum: it.values_at(:is, :minimum).find(&:itself),
87
+ maximum: it.values_at(:is, :maximum).find(&:itself),
88
+ } },
89
+
90
+ *(yield if block_given?)
91
+ ] }
92
+ end
93
+
94
+ # Infers numeric boundaries (minimum, maximum, greater_than,
95
+ # less_than, multiple_of) from comparison and numericality validators.
96
+ def attribute_boundaries name
97
+ super { [
98
+ *[
99
+ *attribute_validators(name, :comparison),
100
+ *attribute_validators(name, :numericality),
101
+ ].map(&:options)
102
+ .map { {
103
+ minimum: it[:greater_than_or_equal_to],
104
+ greater_than: it[:greater_than],
105
+ less_than: it[:less_than],
106
+ maximum: it[:less_than_or_equal_to],
107
+
108
+ multiple_of: it[:multiple_of],
109
+ } },
110
+
111
+ *(yield if block_given?)
112
+ ] }
113
+ end
114
+
115
+ # Returns the validators for an attribute, optionally filtered
116
+ # by validator type (e.g., `:presence`, `:inclusion`, `:format`).
117
+ def attribute_validators name, type = nil
118
+ validators_on(name)
119
+ .grep(ActiveModel::Validations.const_get "#{type.to_s.camelize}Validator")
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,137 @@
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(schema_reflections)
39
+
40
+ memoize def schema_reflections = nested_attributes_options
41
+ .filter_map { |name, options|
42
+ reflection = reflect_on_association name
43
+
44
+ [ name, {
45
+ type: reflection.collection? ? :array : :object,
46
+ of: reflection.klass.schema,
47
+ description: try(:human_attribute_name, name),
48
+ required: reflection_required?(name),
49
+ max_items: options[:limit],
50
+ }.compact ]
51
+ }
52
+ .to_h
53
+
54
+ # Determines whether an attribute is required by checking the column’s
55
+ # null constraint, in addition to any Active Model checks.
56
+ def attribute_required? name
57
+ super { [
58
+ !column_for_attribute(name).null,
59
+
60
+ *(yield if block_given?)
61
+ ] }
62
+ end
63
+
64
+ # Returns the default value for an attribute from the column defaults.
65
+ def attribute_default name
66
+ column_defaults[name.to_s]
67
+ end
68
+
69
+ # Infers enum values from Active Record `enum` declarations and,
70
+ # for the inheritance column, includes valid STI class names (the base
71
+ # class and all descendants).
72
+ def attribute_enum name
73
+ super { [
74
+ defined_enums[name],
75
+
76
+ ([self, *descendants]
77
+ .map(&:name) if
78
+ name.to_s == inheritance_column),
79
+
80
+ *(yield if block_given?)
81
+ ] }
82
+ end
83
+
84
+ # Infers a constant value from acceptance validators that do not allow
85
+ # nil, but only for non-nullable columns.
86
+ def attribute_const name
87
+ super { [
88
+ *attribute_validators(name, :acceptance)
89
+ &.select { it.options[:allow_nil] }
90
+ .presence
91
+ &.any?,
92
+
93
+ *(yield if block_given?)
94
+ ] unless column_for_attribute(name).null }
95
+ end
96
+
97
+ # Infers the maximum string length from the column’s database limit.
98
+ def attribute_length name
99
+ super { [
100
+ {
101
+ maximum: column_for_attribute(name).limit
102
+ },
103
+
104
+ *(yield if block_given?)
105
+ ] }
106
+ end
107
+
108
+ # For decimal columns, sets `multiple_of` based on the column scale
109
+ # (e.g., scale 2 → multiple_of 0.01), ensuring the generated schema
110
+ # rejects values with more decimal places than the column supports.
111
+ def attribute_boundaries name
112
+ super { [
113
+ {
114
+ multiple_of: column_for_attribute(name)
115
+ .then.detect { it.type == :decimal }
116
+ &.then { 10 ** (-it.scale) },
117
+ },
118
+
119
+ *(yield if block_given?)
120
+ ] }
121
+ end
122
+
123
+ def reflection_required? name
124
+ reflect_on_association(name)&.then do
125
+ case it
126
+ when ActiveRecord::Reflection::BelongsToReflection
127
+ not it.options[:optional]
128
+ when ActiveRecord::Reflection::HasOneReflection
129
+ it.options[:required] || false
130
+ else
131
+ false
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ 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&.select(&:many?)&.first
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.3.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
 
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Senko
@@ -75,6 +75,22 @@ dependencies:
75
75
  version: "0"
76
76
  - !ruby/object:Gem::Dependency
77
77
  name: ruby_llm
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ -
81
+ - ~>
82
+ - !ruby/object:Gem::Version
83
+ version: "2.0"
84
+ type: :runtime
85
+ prerelease: false
86
+ version_requirements: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ -
89
+ - ~>
90
+ - !ruby/object:Gem::Version
91
+ version: "2.0"
92
+ - !ruby/object:Gem::Dependency
93
+ name: magic-lookup
78
94
  requirement: !ruby/object:Gem::Requirement
79
95
  requirements:
80
96
  -
@@ -138,12 +154,19 @@ files:
138
154
  - lib/action_ai/implicit_interaction.rb
139
155
  - lib/action_ai/interaction.rb
140
156
  - lib/action_ai/log_subscriber.rb
157
+ - lib/action_ai/model_schema.rb
158
+ - lib/action_ai/model_schema/active_model.rb
159
+ - lib/action_ai/model_schema/active_record.rb
141
160
  - lib/action_ai/parameterized.rb
142
161
  - lib/action_ai/preview.rb
143
162
  - lib/action_ai/prompt_helper.rb
144
163
  - lib/action_ai/queued_execution.rb
145
164
  - lib/action_ai/railtie.rb
165
+ - lib/action_ai/refinements/numeric.rb
166
+ - lib/action_ai/refinements/regexp.rb
146
167
  - lib/action_ai/rescuable.rb
168
+ - lib/action_ai/schema.rb
169
+ - lib/action_ai/structured_output.rb
147
170
  - lib/action_ai/test_case.rb
148
171
  - lib/action_ai/test_helper.rb
149
172
  - lib/action_ai/version.rb
@@ -151,16 +174,16 @@ files:
151
174
  - lib/rails/generators/ai/ai_generator.rb
152
175
  - lib/rails/generators/ai/templates/agent.rb.tt
153
176
  - lib/rails/generators/ai/templates/application_agent.rb.tt
177
+ - lib/ruby_llm/protocols/echo.rb
154
178
  - lib/ruby_llm/providers/test.rb
155
- - lib/ruby_llm/providers/test/echo.rb
156
179
  - lib/ruby_llm/tester.rb
157
180
  homepage: "https://github.com/Alexander-Senko/action_ai"
158
181
  licenses:
159
182
  - MIT
160
183
  metadata:
161
184
  bug_tracker_uri: "https://github.com/Alexander-Senko/action_ai/issues"
162
- changelog_uri: "https://github.com/Alexander-Senko/action_ai/blob/v0.2.0/CHANGELOG.md"
163
- source_code_uri: "https://github.com/Alexander-Senko/action_ai/tree/v0.2.0"
185
+ changelog_uri: "https://github.com/Alexander-Senko/action_ai/blob/v0.3.0/CHANGELOG.md"
186
+ source_code_uri: "https://github.com/Alexander-Senko/action_ai/tree/v0.3.0"
164
187
  rubygems_mfa_required: "true"
165
188
  rdoc_options: []
166
189
  require_paths:
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module Providers
5
- # Echo model behavior for the RubyLLM Test provider.
6
- #
7
- # A deterministic response builder (`#echo_response`) returns
8
- # incoming prompt content.
9
- #
10
- # It is intended to be mixed into `RubyLLM::Providers::Test`.
11
- module Test::Echo
12
- def self.id = "echo"
13
-
14
- def self.info = Model::Info.new(
15
- id:,
16
- name:,
17
- provider: Test.slug,
18
- capabilities: Test.capabilities,
19
-
20
- modalities: {
21
- input: %w[text],
22
- output: %w[text],
23
- },
24
- )
25
-
26
- private
27
-
28
- def echo_response(messages, **) = {
29
- content: RubyLLM.concat_content(messages
30
- .reverse_each.take_while { it.role != :assistant }.reverse
31
- .map(&:content)
32
- ),
33
- }
34
- end
35
- end
36
-
37
- def self.concat_content *parts
38
- strings, contents = parts
39
- .flatten
40
- .partition { it.is_a? String }
41
-
42
- Content.new(
43
- [*strings, *contents.map(&:text)]
44
- .reject(&:empty?)
45
- .join("\n\n"),
46
- contents.sum([], &:attachments)
47
- .map(&:source)
48
- )
49
- end
50
- end