action_ai 0.1.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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.rdoc +129 -1
- data/lib/action_ai/agent.rb +2 -0
- data/lib/action_ai/implicit_interaction.rb +19 -0
- data/lib/action_ai/interaction.rb +17 -0
- data/lib/action_ai/model_schema/active_model.rb +123 -0
- data/lib/action_ai/model_schema/active_record.rb +137 -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 +6 -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 +182 -143
- data/lib/ruby_llm/providers/test/echo.rb +0 -47
|
@@ -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
|
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
|
|
@@ -48,6 +52,8 @@ module ActionAI
|
|
|
48
52
|
autoload :TestCase
|
|
49
53
|
autoload :TestHelper
|
|
50
54
|
autoload :Interaction
|
|
55
|
+
autoload :ImplicitInteraction
|
|
56
|
+
autoload :StructuredOutput
|
|
51
57
|
autoload :ExecutionJob
|
|
52
58
|
autoload :QueuedExecution
|
|
53
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
|
|
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
|