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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5b0c31e7ae5b9944a95f32563d3873adea7134ea32e11ebfc5dbc2a81eafec9
4
- data.tar.gz: d20750877288e1e1306a58a08f6b1aeea02985d749df8ce763262e0cc68314e6
3
+ metadata.gz: 3fa6d979d2013779ecfe3932a2c1bb04158dd99072ef5eb4b802f2ece910b34d
4
+ data.tar.gz: 46cb4d1ad9cca9f63c7af8d575abe3ad23fef1359d2f5d445617662e071b1ef6
5
5
  SHA512:
6
- metadata.gz: 84b92d55d7310f17e3746457cd76ecdae5dbed20aee80276b61d30824b38093d13c3f4753b69925e819dac9471bcfd5d1c1733aa46c78dd68969d0350c24a0c7
7
- data.tar.gz: 0bbec9e620ec3fa03151b8dc4d6223b8a1de5d6b18cb4731c96df73d1bd2a9fb25a862c4dc3cbe27093468376a61c90d32f6763ef95f88f9c2f78118a077934e
6
+ metadata.gz: 0de59f1a428c9be0cd4e1926a8f1d59f1cca46c8fbdc77248fe34ab21c755412c39db3bfabcaa86855be878e707cfdb61e2d74a983c7ae2625de50eed3b96184
7
+ data.tar.gz: 4e77dc58ce51dc7a8d9027cabdab9465b82b3e9abccce7bbfb3591f8fd12cd36dbd4f28953118ec41ec3bc9f64b0569045d3e191c362ba8dbdcb9decaa9312e6
data/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+
6
+ ## [0.4.0] - 2026-10-20
7
+
8
+ ### Changed
9
+
10
+ - Moved agent-level configuration to RubyLLM agents.
11
+
12
+ ### Added
13
+
14
+ #### Configuration
15
+
16
+ - RubyLLM agent configuration DSL support on `ActionAI::Agent`: class methods `model`, `instructions`, `tools`, `tool_options`, `server_tools`, `thinking`, `citations`, `caching`, `compaction`, `provider_options`, `headers`, `schema`, `fallbacks`, `context`, `chat_model`, `inputs`, `temperature`, `max_output_tokens`, and `end_user` configure a private, internally managed `RubyLLM::Agent` subclass.
17
+ `chat` is built via that agent’s `.chat` instead of plain `RubyLLM.chat`.
18
+ - Parameters set through `ActionAI::Parameterized` are forwarded as the agent’s runtime `inputs`.
19
+
20
+ #### Schemas
21
+
22
+ - Consider Active Record _stored attributes_ for schema inference.
23
+ The type is `string` because `ActiveRecord::Store` doesn’t provide type casting. The same is true for the attributes a model will get from a user-filled form, so the behavior is consistent. One should override attribute setters for type casting if needed.
24
+
25
+ ### Removed
26
+
27
+ #### Configuration
28
+
29
+ - `default`/`default_options=` class method and `default_params` class attribute.
30
+ Use the RubyLLM agent DSL (`model`, `instructions`, and so on) instead.
31
+
32
+
33
+ ## [0.3.0] - 2026-10-13
34
+
35
+ ### Changed
36
+
37
+ - Moved to RubyLLM 2.
38
+ - Dropped `echo` test model in favor of `echo` protocol.
39
+
40
+ ### Added
41
+
42
+ #### Structured output
43
+
44
+ - `.schema` class method on any `ActiveModel::API` model, returning a cached `Schematist::Schema` instance derived from the model’s attribute types and constraints.
45
+ - Include Active Record associations as nested schemas via objects/arrays.
46
+ Consider associations registered for _nested attributes_ only.
47
+ - Prefer custom schemas over inferred ones.
48
+ Define them as `<ModelName>Schema < ActionAI::Schema` classes under `app/schemas`.
49
+ - `#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.
50
+ - Use Strong Parameters to decorate returned JSON for security reasons.
51
+ AI model’s output shouldn’t be considered a trusted input.
52
+ - `#returns([ModelClass])` array syntax: configures an array schema and `#object` returns an array of model instances.
53
+
54
+
5
55
  ## [0.2.0] - 2026-06-30
6
56
 
7
57
  ### Added
@@ -13,6 +63,7 @@ All notable changes to this project will be documented in this file.
13
63
 
14
64
  - `echo` test model to support multiple chat interactions.
15
65
 
66
+
16
67
  ## [0.1.0] - 2026-05-05
17
68
 
18
69
  Refactored from Action Mailer.
data/README.rdoc CHANGED
@@ -22,7 +22,7 @@ automatically at the end of the action unless you have already called it.
22
22
  This can be as simple as:
23
23
 
24
24
  class Generator < ApplicationAI
25
- default model: "gpt-4o"
25
+ model "gpt-6.1-sol"
26
26
 
27
27
  def code(task, language)
28
28
  @task = task
@@ -88,33 +88,163 @@ You can also chain multiple agent actions to compose a small workflow before rea
88
88
  WorkflowAgent.collect("release notes").refine(style: "bullet list").content
89
89
  # => collected release notes refined as bullet list
90
90
 
91
- == Setting defaults
91
+ == Configuration
92
+
93
+ Your agent class uses the full +RubyLLM::Agent+ configuration DSL
94
+ directly. You never need to define or reference a +RubyLLM::Agent+
95
+ subclass yourself; just call +model+, +instructions+, +tools+, +schema+, and
96
+ the other +RubyLLM::Agent+ class macros right on your Action AI agent:
92
97
 
93
- It is possible to set default values that will be used in every method in your
94
- Action AI Agent class. To implement this functionality, you just call the public
95
- class method +default+ which you get for free from ActionAI::Agent.
96
- This method accepts a Hash as the parameter. You can use any options
97
- supported by +RubyLLM::Chat+, such as +:provider+ and +:model+.
98
- Finally, it is also possible to pass in a
99
- Proc that will get evaluated when it is needed.
98
+ class Generator < ApplicationAI
99
+ model "gpt-6.1-sol"
100
+ instructions "You are a concise support assistant."
101
+ tools SearchDocs, LookupAccount
100
102
 
101
- Note that every value you set with this method will get overwritten if you use the
102
- same key in your agent method.
103
+ def code(task, language)
104
+ @task = task
105
+ @language = language
106
+ end
107
+ end
103
108
 
104
- Example:
109
+ If you declare +inputs+, use ActionAI::Parameterized to supply them at call
110
+ time; the parameters given to +with+ are forwarded as the agent's runtime
111
+ inputs:
105
112
 
106
113
  class Generator < ApplicationAI
107
- default model: proc { Current.user.preferred_model }
114
+ inputs :workspace
115
+
116
+ instructions { "You are helping #{workspace.name}" }
108
117
  end
109
118
 
110
- == Configuration
119
+ Generator.with(workspace:).code("Parse CSV", :ruby)
120
+
121
+ Subclasses inherit their parent's RubyLLM agent configuration and can extend
122
+ or override it, the same way +RubyLLM::Agent+ subclasses do:
111
123
 
112
- The Agent class has the full list of configuration options. Here's an example:
124
+ class ApplicationAI < ActionAI::Agent
125
+ model "gpt-6-luna", provider: :openai
126
+ end
127
+
128
+ class Generator < ApplicationAI
129
+ model { Current.user.preferred_model }
130
+ end
131
+
132
+ The Agent class has a few settings unrelated to the RubyLLM agent DSL that are specified on the class level.
133
+ Here's an example:
134
+
135
+ ActionAI::Agent.execute_later_queue_name = :ai_agents
136
+
137
+ * +logger+ — the logger is used for generating information on prompt execution if available.
138
+ Can be set to +nil+ for no logging. Compatible with both Ruby's own +Logger+ and Log4r loggers.
139
+
140
+ * +execution_job+ — The job class used with +later+. Agents can set this to use a
141
+ custom execution job. Defaults to +ActionAI::ExecutionJob+.
142
+
143
+ * +execute_later_queue_name+ — The queue name used by +later+ with the default
144
+ +execution_job+. Agents can set this to use a custom queue name.
145
+
146
+ == Structured output (data schemas)
147
+
148
+ Action AI can instruct the model to return structured JSON that maps directly
149
+ to an ActiveModel model. Two features cooperate to make this work:
150
+
151
+ === +.schema+ on ActiveModel models
152
+
153
+ Any model that includes +ActiveModel::API+ automatically gains a +.schema+
154
+ class method that returns a +Schematist::Schema+ instance derived from the model's
155
+ attribute types.
156
+
157
+ class Person
158
+ include ActiveModel::Model
159
+ include ActiveModel::Attributes
160
+
161
+ attribute :name, :string
162
+ attribute :age, :integer
163
+ attribute :score, :float
164
+ end
113
165
 
114
- ActionAI::Agent.default_options = {
115
- provider: :openai,
116
- model: "gpt-4o-mini"
117
- }
166
+ Person.schema # => a Schematist::Schema instance
167
+ Person.schema.new.to_json_schema # => {name: "Person", schema: {...}}
168
+
169
+ Supported type mappings:
170
+
171
+ * +:string+, +:immutable_string+, +:text+ → JSON +string+
172
+ * +:integer+, +:big_integer+ → JSON +integer+
173
+ * +:float+, +:decimal+, +:big_decimal+ → JSON +number+
174
+ * +:boolean+ → JSON +boolean+
175
+ * +:date+, +:datetime+, +:time+ → JSON +string+ with +format+ set to
176
+ +"date"+, +"date-time"+, or +"time"+ respectively
177
+ * +:binary+ → JSON +string+ with +contentEncoding+ set to +"base64"+
178
+
179
+ Other types are not included in the schema.
180
+
181
+ Active Record associations are included as nested schemas via +object+/+array+.
182
+ To prevent unnescessary nesting or circular dependencies, the schema will include
183
+ associations registered for _nested attributes_ (via +accepts_nested_attributes_for+) only.
184
+
185
+ === +returns+ in agent actions
186
+
187
+ Call +returns ModelClass+ inside an action method to declare the expected
188
+ return type. Action AI will:
189
+
190
+ 1. Configure the chat to request output matching the model's JSON schema
191
+ (+chat.with_schema ModelClass.schema+).
192
+ 2. Decorate the resulting +RubyLLM::Message+ with Strong Parameters.
193
+ 3. Construct a model instance accessible via +#object+.
194
+
195
+ class Extractor < ApplicationAI
196
+ def person(text)
197
+ @text = text
198
+
199
+ returns Person do |params|
200
+ params.permit(:name, :age)
201
+ end
202
+ end
203
+ end
204
+
205
+ result = Extractor.person("Alice is 30 years old")
206
+ result.content # => '{"name":"Alice","age":30}'
207
+ result.parsed # => {"name" => "Alice", "age" => 30}
208
+ result.object # => #<Person name="Alice" age=30>
209
+
210
+ Pass the class wrapped in an array to declare an array return type:
211
+
212
+ class Extractor < ApplicationAI
213
+ def people(text)
214
+ @text = text
215
+
216
+ returns [Person] do |params|
217
+ params.permit(:name, :age)
218
+ end
219
+ end
220
+ end
221
+
222
+ result = Extractor.people("Alice is 30 and Bob is 25")
223
+ result.content # => '[{"name":"Alice","age":30},{"name":"Bob","age":25}]'
224
+ result.parsed # => [{"name" => "Alice", "age" => 30}, {"name" => "Bob", "age" => 25}]
225
+ result.object # => [#<Person name="Alice" age=30>, #<Person name="Bob" age=25>]
226
+
227
+ The schema is built from the model's declared attributes and cached.
228
+
229
+ === Custom schemas
230
+
231
+ If an inferred schema is not sufficient, define a +Schematist::Schema+
232
+ subclass under +app/schemas+ with the model's full class name followed by
233
+ +Schema+. Custom schemas are looked up first; models without a matching class
234
+ continue to use the inferred schema.
235
+
236
+ # app/schemas/person_schema.rb
237
+ class PersonSchema < ActionAI::Schema
238
+ string :name
239
+ integer :age, minimum: 0
240
+ end
241
+
242
+ For namespaced models, mirror the namespace in +app/schemas+:
243
+
244
+ # app/schemas/admin/person_schema.rb
245
+ class Admin::PersonSchema < ActionAI::Schema
246
+ string :name
247
+ end
118
248
 
119
249
 
120
250
  == Download and installation
@@ -4,6 +4,9 @@ require "ruby_llm"
4
4
  require "active_support/core_ext/string/inflections"
5
5
  require "active_support/core_ext/hash/except"
6
6
  require "active_support/core_ext/module/anonymous"
7
+ require "active_support/core_ext/module/delegation"
8
+ require "active_support/core_ext/object/try"
9
+ require "active_support/core_ext/object/with_options"
7
10
  require "memery"
8
11
 
9
12
  require "action_ai/log_subscriber"
@@ -26,12 +29,11 @@ module ActionAI
26
29
  # the prompt views, options on the AI model used such as the <tt>:model</tt> ID, and attachments.
27
30
  #
28
31
  # class ApplicationAI < ActionAI::Agent
29
- # default model: 'gpt-4o-mini',
30
- # provider: :openai
32
+ # model "gpt-6-luna", provider: :openai
31
33
  # end
32
34
  #
33
35
  # class Generator < ApplicationAI
34
- # default model: 'gpt-4o'
36
+ # model "gpt-6.1-sol"
35
37
  #
36
38
  # def code(task, language)
37
39
  # @task = task
@@ -155,33 +157,40 @@ module ActionAI
155
157
  # end
156
158
  # end
157
159
  #
158
- # == Default \Hash
160
+ # == Configuration
159
161
  #
160
- # Action AI provides some intelligent defaults for your AI interactions, these are usually specified in a
161
- # default method inside the class definition:
162
+ # Action AI agents configure their model, instructions, tools, and other
163
+ # chat options using the +RubyLLM::Agent+ class DSL directly. You never
164
+ # need to define or reference a +RubyLLM::Agent+ subclass yourself; just
165
+ # call +model+, +instructions+, +tools+, +schema+, and the other
166
+ # +RubyLLM::Agent+ class macros right on your +ActionAI::Agent+ subclass:
162
167
  #
163
168
  # class Generator < ApplicationAI
164
- # default model: 'gpt-4o'
169
+ # model "gpt-6.1-sol"
170
+ # instructions "You are a concise support assistant."
171
+ # tools SearchDocs, LookupAccount
165
172
  # end
166
173
  #
167
- # You can pass in any config value that a +RubyLLM::Chat+ accepts.
174
+ # Under the hood, Action AI manages a private +RubyLLM::Agent+ subclass for you
175
+ # and builds +chat+ from it, so instructions, tools, schema, fallbacks, caching,
176
+ # and every other declaration are honored exactly as documented on
177
+ # <tt>RubyLLM::Agent</tt>. Any params set through ActionAI::Parameterized
178
+ # (<tt>Generator.with(...)</tt>) are forwarded as its runtime +inputs+.
168
179
  #
169
- # Finally, Action AI also supports passing <tt>Proc</tt> and <tt>Lambda</tt> objects into the default hash,
170
- # so you can define methods that evaluate as the message is being generated:
180
+ # Configuration is inherited the same way +RubyLLM::Agent+ subclasses inherit
181
+ # it, so a shared +ApplicationAI+ base class can declare a default +model+ and
182
+ # +instructions+ that every agent subclass then extends or overrides:
171
183
  #
172
- # class Generator < ApplicationAI
173
- # default model: -> { Current.user.preferred_model },
174
- # api_key: proc { Current.user.ai_api_key }
184
+ # class ApplicationAI < ActionAI::Agent
185
+ # model "gpt-6-luna", provider: :openai
175
186
  # end
176
187
  #
177
- # Note that the proc/lambda is evaluated right at the start of the prompt generation, so if you
178
- # set something in the default hash using a proc, and then set the same thing inside of your
179
- # agent method, it will get overwritten by the agent method.
180
- #
181
- # It is also possible to set these default options that will be used in all agents through
182
- # the <tt>default_options=</tt> configuration in <tt>config/application.rb</tt>:
188
+ # class Generator < ApplicationAI
189
+ # model { Current.user.preferred_model }
190
+ # end
183
191
  #
184
- # config.action_ai.default_options = { provider: :openai, model: "gpt-4o-mini" }
192
+ # ::rescue_from continues to handle agent-action level exceptions; use it as
193
+ # usual, there's no separate chat-level rescue mechanism to reach for.
185
194
  #
186
195
  # == \Callbacks
187
196
  #
@@ -265,9 +274,6 @@ module ActionAI
265
274
  # These options are specified on the class level, like
266
275
  # <tt>ActionAI::Agent.raise_execution_errors = true</tt>
267
276
  #
268
- # * <tt>default_options</tt> - You can pass this in at a class level as well as within the class itself as
269
- # per the above section.
270
- #
271
277
  # * <tt>logger</tt> - the logger is used for generating information on prompt execution if available.
272
278
  # Can be set to +nil+ for no logging. Compatible with both Ruby's own +Logger+ and Log4r loggers.
273
279
  #
@@ -279,6 +285,7 @@ module ActionAI
279
285
  class Agent < AbstractController::Base
280
286
  include Callbacks
281
287
  include ImplicitInteraction
288
+ prepend StructuredOutput
282
289
  include QueuedExecution
283
290
  include Rescuable
284
291
  include Parameterized
@@ -303,11 +310,9 @@ module ActionAI
303
310
 
304
311
  helper ActionAI::PromptHelper
305
312
 
306
- class_attribute :default_params, default: {
307
- # none
308
- }.freeze
309
-
310
313
  class << self
314
+ include Memery
315
+
311
316
  # Returns the name of the current agent. This method is also being used as a path for a view lookup.
312
317
  # If this is an anonymous agent, this method will return +anonymous+ instead.
313
318
  def agent_name
@@ -317,18 +322,18 @@ module ActionAI
317
322
  attr_writer :agent_name
318
323
  alias :controller_path :agent_name
319
324
 
320
- # Allows to set defaults through app configuration:
321
- #
322
- # config.action_ai.default_options = { provider: :openai }
323
- def default(value = nil)
324
- self.default_params = default_params.merge(value).freeze if value
325
- default_params
325
+ # The private, per-agent-class +RubyLLM::Agent+ subclass,
326
+ # lazily created and configured via its own class macros
327
+ # (+model+, +instructions+, +tools+, +schema+, and so on).
328
+ memoize def agent_class
329
+ Class.new superclass.try(:agent_class) || RubyLLM::Agent
326
330
  end
327
- alias :default_options= :default
328
331
 
329
332
  private
330
333
  def method_missing(method_name, ...)
331
- if action_methods.include?(method_name.name)
334
+ if agent_class.respond_to? method_name # configuration
335
+ agent_class.public_send(method_name, ...)
336
+ elsif action_methods.include? method_name.name # actions
332
337
  Interaction.new(self, method_name, ...)
333
338
  else
334
339
  super
@@ -336,13 +341,22 @@ module ActionAI
336
341
  end
337
342
 
338
343
  def respond_to_missing?(method, include_all = false)
339
- action_methods.include?(method.name) || super
344
+ agent_class.respond_to? method or
345
+ action_methods.include? method.name or
346
+ super
340
347
  end
341
348
  end
342
349
 
343
350
  attr_internal :message
344
351
 
345
- memoize def chat = RubyLLM.chat(**apply_defaults)
352
+ memoize def agent = agent_class.new(**params
353
+ .slice(*agent_class.inputs)
354
+ )
355
+
356
+ with_options private: true do
357
+ delegate :agent_class, to: :class
358
+ delegate :chat, to: :agent
359
+ end
346
360
 
347
361
  def process(method_name, *args) # :nodoc:
348
362
  payload = {
@@ -378,10 +392,10 @@ module ActionAI
378
392
  #
379
393
  # * +:with+ - Array of file paths or URLs to attach to the prompt.
380
394
  #
381
- # You can set default model options using the ::default class method:
395
+ # You can set default model options using the ::model class method:
382
396
  #
383
397
  # class Generator < ActionAI::Agent
384
- # default model: 'gpt-4o', provider: :openai
398
+ # model "gpt-6.1-sol", provider: :openai
385
399
  # end
386
400
  #
387
401
  # It will find a template in the view paths using by default the agent name and the
@@ -391,7 +405,7 @@ module ActionAI
391
405
  # For example:
392
406
  #
393
407
  # class Generator < ActionAI::Agent
394
- # default model: 'gpt-4o'
408
+ # model "gpt-6.1-sol"
395
409
  #
396
410
  # def code(task, language)
397
411
  # @task = task
@@ -422,24 +436,6 @@ module ActionAI
422
436
  false
423
437
  end
424
438
 
425
- def apply_defaults(config = {})
426
- default_values = self.class.default.except(*config.keys).transform_values do |value|
427
- compute_default(value)
428
- end
429
-
430
- config.reverse_merge(default_values)
431
- end
432
-
433
- def compute_default(value)
434
- return value unless value.is_a?(Proc)
435
-
436
- if value.arity == 1
437
- instance_exec(self, &value)
438
- else
439
- instance_exec(&value)
440
- end
441
- end
442
-
443
439
  def use_attachments
444
440
  attachments.presence
445
441
  .tap { @attachments = nil } # reset
@@ -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