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 +4 -4
- data/CHANGELOG.md +51 -0
- data/README.rdoc +149 -19
- data/lib/action_ai/agent.rb +54 -58
- data/lib/action_ai/model_schema/active_model.rb +123 -0
- data/lib/action_ai/model_schema/active_record.rb +151 -0
- data/lib/action_ai/model_schema.rb +195 -0
- data/lib/action_ai/railtie.rb +8 -1
- data/lib/action_ai/refinements/numeric.rb +32 -0
- data/lib/action_ai/refinements/regexp.rb +13 -0
- data/lib/action_ai/schema.rb +20 -0
- data/lib/action_ai/structured_output.rb +85 -0
- data/lib/action_ai/version.rb +1 -1
- data/lib/action_ai.rb +5 -0
- data/lib/rails/generators/ai/templates/agent.rb.tt +4 -0
- data/lib/ruby_llm/protocols/echo.rb +26 -0
- data/lib/ruby_llm/providers/test.rb +5 -21
- data/lib/ruby_llm/tester.rb +7 -3
- metadata +27 -4
- data/lib/ruby_llm/providers/test/echo.rb +0 -50
|
@@ -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
|
data/lib/action_ai/railtie.rb
CHANGED
|
@@ -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/
|
|
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,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
|
data/lib/action_ai/version.rb
CHANGED
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
|
|
12
|
-
# (for example, `echo_response` from `Test::Echo`).
|
|
12
|
+
# - it returns deterministic responses through `echo` protocol.
|
|
13
13
|
class Test < Provider
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
include Echo
|
|
14
|
+
protocol :echo, Protocols::Echo
|
|
17
15
|
|
|
18
16
|
def self.local? = true
|
|
19
17
|
|
|
20
|
-
def initialize(
|
|
21
|
-
|
|
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
|
data/lib/ruby_llm/tester.rb
CHANGED
|
@@ -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+
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
Provider.register :test, Providers::Test
|
|
40
|
+
|
|
41
|
+
configure do |config|
|
|
42
|
+
config.model_registry_file = "test/models.json"
|
|
43
|
+
end
|
|
40
44
|
end
|