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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.rdoc +104 -0
- data/lib/action_ai/agent.rb +1 -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 +5 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 626b0d7eb4998d7c208dc99eb8e60932a3006988176bbd784935f1e00437b485
|
|
4
|
+
data.tar.gz: 97512e0c8fd015af6235d61b31ed36e7030702ddaf4b436b18b462b39013bd55
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
data/lib/action_ai/agent.rb
CHANGED
|
@@ -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
|
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
|
|
|
@@ -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
|
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.
|
|
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.
|
|
163
|
-
source_code_uri: "https://github.com/Alexander-Senko/action_ai/tree/v0.
|
|
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
|