ruby_llm-agent-evaluations 0.3.0 → 0.3.1
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 +6 -0
- data/README.md +30 -14
- data/examples/a2ui_eval.rb +5 -19
- data/lib/ruby_llm/evaluations/a2ui/assets/LICENSE +227 -0
- data/lib/ruby_llm/evaluations/a2ui/assets/PROVENANCE.json +10 -0
- data/lib/ruby_llm/evaluations/a2ui/assets/v0_8/server_to_client.json +148 -0
- data/lib/ruby_llm/evaluations/a2ui/assets/v0_8/standard_catalog_definition.json +675 -0
- data/lib/ruby_llm/evaluations/a2ui/catalogs.rb +135 -0
- data/lib/ruby_llm/evaluations/a2ui/document.rb +275 -0
- data/lib/ruby_llm/evaluations/a2ui/evaluator.rb +37 -0
- data/lib/ruby_llm/evaluations/a2ui/expectations.rb +97 -0
- data/lib/ruby_llm/evaluations/suite.rb +7 -0
- data/lib/ruby_llm/evaluations/version.rb +1 -1
- data/lib/ruby_llm/evaluations.rb +4 -0
- metadata +24 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "json_schemer"
|
|
6
|
+
require_relative "../data"
|
|
7
|
+
|
|
8
|
+
module RubyLLM
|
|
9
|
+
module Evaluations
|
|
10
|
+
module A2UI
|
|
11
|
+
class Catalogs
|
|
12
|
+
STANDARD_ID = "https://a2ui.org/specification/v0_8/standard_catalog_definition.json".freeze
|
|
13
|
+
ASSETS = File.expand_path("assets/v0_8", __dir__).freeze
|
|
14
|
+
OFFLINE_RESOLVER = proc { |uri| raise ArgumentError, "external or unresolved schema reference: #{uri}" }.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :definitions
|
|
17
|
+
|
|
18
|
+
def initialize(custom_catalogs = {})
|
|
19
|
+
raise ArgumentError, "catalogs must be a Hash" unless custom_catalogs.is_a?(Hash)
|
|
20
|
+
standard = load_definition(File.join(ASSETS, "standard_catalog_definition.json"))
|
|
21
|
+
standard["catalogId"] = STANDARD_ID
|
|
22
|
+
@definitions = { STANDARD_ID => standard }
|
|
23
|
+
custom_catalogs.each do |id, source|
|
|
24
|
+
raise ArgumentError, "catalog ID must be a string or symbol" unless id.is_a?(String) || id.is_a?(Symbol)
|
|
25
|
+
id = id.to_s
|
|
26
|
+
raise ArgumentError, "cannot override standard catalog" if id == STANDARD_ID || id == "standard"
|
|
27
|
+
raise ArgumentError, "duplicate catalog ID #{id.inspect}" if @definitions.key?(id)
|
|
28
|
+
definition = load_definition(source)
|
|
29
|
+
if definition.key?("catalogId") && definition["catalogId"] != id
|
|
30
|
+
raise ArgumentError, "catalog #{id.inspect}: catalogId must match its configured ID"
|
|
31
|
+
end
|
|
32
|
+
definition["catalogId"] = id
|
|
33
|
+
@definitions[id] = definition
|
|
34
|
+
end
|
|
35
|
+
@components = {}
|
|
36
|
+
@styles = {}
|
|
37
|
+
@definitions.each { |id, definition| configure(id, definition) }
|
|
38
|
+
@message_schema = compile(JSON.parse(File.read(File.join(ASSETS, "server_to_client.json"))), "message schema")
|
|
39
|
+
freeze_tree(@definitions)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def known?(id)
|
|
43
|
+
@definitions.key?(id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def message_errors(message)
|
|
47
|
+
errors(@message_schema, message, "message")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def component_errors(catalog_id, type, props)
|
|
51
|
+
id = catalog_id
|
|
52
|
+
return ["unknown catalog #{catalog_id.inspect}"] unless known?(id)
|
|
53
|
+
schema = @components.fetch(id)[type.to_s]
|
|
54
|
+
return ["catalog #{id.inspect}: unknown component type #{type.inspect}"] unless schema
|
|
55
|
+
errors(schema, props, "component #{type.inspect}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def style_errors(catalog_id, styles)
|
|
59
|
+
id = catalog_id
|
|
60
|
+
return ["unknown catalog #{catalog_id.inspect}"] unless known?(id)
|
|
61
|
+
return ["styles must be an object"] unless styles.is_a?(Hash)
|
|
62
|
+
styles.flat_map do |name, value|
|
|
63
|
+
schema = @styles.fetch(id)[name.to_s]
|
|
64
|
+
schema ? errors(schema, value, "style #{name.inspect}") : ["catalog #{id.inspect}: unknown style #{name.inspect}"]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load_definition(source)
|
|
71
|
+
value = case source
|
|
72
|
+
when Hash then Data.snapshot(source)
|
|
73
|
+
when String, Pathname then JSON.parse(File.read(source.to_s))
|
|
74
|
+
else raise ArgumentError, "catalog definition must be a Hash or local JSON file path"
|
|
75
|
+
end
|
|
76
|
+
raise ArgumentError, "catalog definition must be an object" unless value.is_a?(Hash)
|
|
77
|
+
value
|
|
78
|
+
rescue JSON::ParserError, SystemCallError => error
|
|
79
|
+
raise ArgumentError, "invalid catalog definition: #{error.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def configure(id, definition)
|
|
83
|
+
compile(definition.slice("$schema", "$defs", "definitions"), "catalog #{id.inspect}")
|
|
84
|
+
%w[components styles].each do |section|
|
|
85
|
+
entries = definition[section]
|
|
86
|
+
raise ArgumentError, "catalog #{id.inspect}: #{section} must be an object" unless entries.is_a?(Hash)
|
|
87
|
+
compiled = entries.to_h do |name, schema|
|
|
88
|
+
# Catalog-level definitions are shared; components can override them locally.
|
|
89
|
+
schema = definition.slice("$schema", "$defs", "definitions").merge(schema) if schema.is_a?(Hash)
|
|
90
|
+
[name, compile(schema, "catalog #{id.inspect} #{section}/#{name}")]
|
|
91
|
+
end
|
|
92
|
+
(section == "components" ? @components : @styles)[id] = compiled
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def compile(schema, label)
|
|
97
|
+
unless schema.is_a?(Hash) || schema == true || schema == false
|
|
98
|
+
raise ArgumentError, "#{label}: schema must be an object or boolean"
|
|
99
|
+
end
|
|
100
|
+
compiled = JSONSchemer.schema(schema, ref_resolver: OFFLINE_RESOLVER)
|
|
101
|
+
failures = compiled.validate_schema.to_a
|
|
102
|
+
unless failures.empty?
|
|
103
|
+
raise ArgumentError, "#{label}: malformed schema: #{failures.map { |error| error['error'] }.join('; ')}"
|
|
104
|
+
end
|
|
105
|
+
# Resolve real schema references eagerly; annotation/default data is not schema.
|
|
106
|
+
compiled.bundle
|
|
107
|
+
compiled
|
|
108
|
+
rescue ArgumentError
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError => error
|
|
111
|
+
raise ArgumentError, "#{label}: invalid schema: #{error.message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def errors(schema, value, label)
|
|
115
|
+
schema.validate(Data.json_value(value)).map do |error|
|
|
116
|
+
pointer = error.fetch("data_pointer")
|
|
117
|
+
"#{label} at #{pointer.empty? ? '/' : pointer}: #{error['error'] || error['type']}"
|
|
118
|
+
end
|
|
119
|
+
rescue ArgumentError => error
|
|
120
|
+
["#{label}: #{error.message}"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def freeze_tree(value)
|
|
124
|
+
case value
|
|
125
|
+
when Hash
|
|
126
|
+
value.each { |key, item| key.freeze; freeze_tree(item) }
|
|
127
|
+
when Array
|
|
128
|
+
value.each { |item| freeze_tree(item) }
|
|
129
|
+
end
|
|
130
|
+
value.freeze
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Evaluations
|
|
7
|
+
module A2UI
|
|
8
|
+
# Replays protocol messages without rendering or resolving remote catalogs.
|
|
9
|
+
class Document
|
|
10
|
+
ACTIONS = %w[surfaceUpdate beginRendering dataModelUpdate deleteSurface].freeze
|
|
11
|
+
VALUES = %w[valueString valueNumber valueBoolean valueMap].freeze
|
|
12
|
+
private_constant :ACTIONS, :VALUES
|
|
13
|
+
|
|
14
|
+
attr_reader :errors, :surfaces
|
|
15
|
+
|
|
16
|
+
def initialize(catalogs)
|
|
17
|
+
@catalogs = catalogs
|
|
18
|
+
@errors = []
|
|
19
|
+
@surfaces = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read(response_components)
|
|
23
|
+
@errors = []
|
|
24
|
+
@surfaces = {}
|
|
25
|
+
@records = []
|
|
26
|
+
@locations = {}
|
|
27
|
+
@component_locations = {}
|
|
28
|
+
@message_index = 0
|
|
29
|
+
unless response_components.is_a?(Array) && !response_components.empty?
|
|
30
|
+
errors << "A2UI requires a nonempty response component envelope array"
|
|
31
|
+
return self
|
|
32
|
+
end
|
|
33
|
+
response_components.each_with_index do |envelope, index|
|
|
34
|
+
unless envelope.is_a?(Hash) && envelope.key?("payload")
|
|
35
|
+
errors << "Envelope #{index + 1}: expected an object containing payload"
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
messages(envelope["payload"], "Envelope #{index + 1}").each do |message|
|
|
39
|
+
@message_index += 1
|
|
40
|
+
replay(message)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
validate_records
|
|
44
|
+
validate_surfaces
|
|
45
|
+
unless surfaces.values.any? { |surface| surface["rendered"] }
|
|
46
|
+
errors << "A2UI requires at least one live rendered surface"
|
|
47
|
+
end
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def messages(payload, location)
|
|
54
|
+
if payload.is_a?(String)
|
|
55
|
+
if payload.strip.empty?
|
|
56
|
+
errors << "#{location}: payload is empty"
|
|
57
|
+
return []
|
|
58
|
+
end
|
|
59
|
+
begin
|
|
60
|
+
payload = JSON.parse(payload)
|
|
61
|
+
rescue JSON::ParserError => error
|
|
62
|
+
lines = payload.lines.reject { |line| line.strip.empty? }
|
|
63
|
+
if lines.size < 2
|
|
64
|
+
errors << "#{location}: malformed JSON: #{error.message}"
|
|
65
|
+
return []
|
|
66
|
+
end
|
|
67
|
+
return lines.each_with_index.flat_map do |line, index|
|
|
68
|
+
begin
|
|
69
|
+
messages(JSON.parse(line), "#{location}, JSONL line #{index + 1}")
|
|
70
|
+
rescue JSON::ParserError => line_error
|
|
71
|
+
errors << "#{location}, JSONL line #{index + 1}: malformed JSON: #{line_error.message}"
|
|
72
|
+
[]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
case payload
|
|
78
|
+
when Hash then [payload]
|
|
79
|
+
when Array
|
|
80
|
+
errors << "#{location}: message array is empty" if payload.empty?
|
|
81
|
+
payload
|
|
82
|
+
else
|
|
83
|
+
errors << "#{location}: payload must be a message object, message array, or JSON/JSONL string"
|
|
84
|
+
[]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def replay(message)
|
|
89
|
+
location = "Message #{@message_index}"
|
|
90
|
+
unless message.is_a?(Hash)
|
|
91
|
+
errors << "#{location}: message must be an object"
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
actions = ACTIONS.select { |action| message.key?(action) }
|
|
95
|
+
if actions.size != 1
|
|
96
|
+
errors << "#{location}: message must contain exactly one action (#{ACTIONS.join(', ')})"
|
|
97
|
+
end
|
|
98
|
+
schema_errors = @catalogs.message_errors(message)
|
|
99
|
+
action = actions.first
|
|
100
|
+
body = message[action]
|
|
101
|
+
if body.is_a?(Hash)
|
|
102
|
+
location += ", surface #{body['surfaceId'].inspect}, /#{action}"
|
|
103
|
+
end
|
|
104
|
+
schema_errors.each { |error| errors << "#{location}: #{error}" }
|
|
105
|
+
return unless actions.size == 1 && schema_errors.empty?
|
|
106
|
+
|
|
107
|
+
id = body["surfaceId"]
|
|
108
|
+
if action == "deleteSurface"
|
|
109
|
+
surfaces.delete(id)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
surface = surfaces[id] ||= {
|
|
113
|
+
"root" => nil, "catalog" => Catalogs::STANDARD_ID,
|
|
114
|
+
"components" => {}, "styles" => {}, "rendered" => false
|
|
115
|
+
}
|
|
116
|
+
@locations[surface.object_id] = location
|
|
117
|
+
case action
|
|
118
|
+
when "surfaceUpdate"
|
|
119
|
+
seen = {}
|
|
120
|
+
body["components"].each_with_index do |component, index|
|
|
121
|
+
component_id = component["id"]
|
|
122
|
+
path = "#{location}/components/#{index}, component #{component_id.inspect}"
|
|
123
|
+
errors << "#{path}: duplicate component ID within this update" if seen[component_id]
|
|
124
|
+
seen[component_id] = true
|
|
125
|
+
wrapper = component["component"]
|
|
126
|
+
unless wrapper.size == 1
|
|
127
|
+
errors << "#{path}/component: expected exactly one component type"
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
type, props = wrapper.first
|
|
131
|
+
record = { "type" => type, "props" => copy(props) }
|
|
132
|
+
surface["components"][component_id] = record
|
|
133
|
+
(@component_locations[surface.object_id] ||= {})[component_id] = path
|
|
134
|
+
catalog = surface["rendered"] ? surface["catalog"] : surface
|
|
135
|
+
@records << [catalog, :component, record, "#{path}/component/#{type}"]
|
|
136
|
+
end
|
|
137
|
+
when "beginRendering"
|
|
138
|
+
catalog = copy(body.fetch("catalogId", Catalogs::STANDARD_ID))
|
|
139
|
+
if surface["rendered"] && surface["catalog"] != catalog
|
|
140
|
+
surface["components"].each do |component_id, component|
|
|
141
|
+
path = "#{location}, component #{component_id.inspect}/component/#{component['type']}"
|
|
142
|
+
@records << [catalog, :component, component, path]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
@records.each { |record| record[0] = catalog if record[0].equal?(surface) }
|
|
146
|
+
surface["root"] = copy(body["root"])
|
|
147
|
+
surface["catalog"] = catalog
|
|
148
|
+
surface["styles"] = copy(body.fetch("styles", {}))
|
|
149
|
+
surface["rendered"] = true
|
|
150
|
+
@records << [catalog, :styles, surface["styles"], "#{location}/styles"]
|
|
151
|
+
unless @catalogs.known?(surface["catalog"])
|
|
152
|
+
errors << "#{location}/catalogId: unknown catalog #{surface['catalog'].inspect} (only registered offline catalogs are allowed)"
|
|
153
|
+
end
|
|
154
|
+
when "dataModelUpdate"
|
|
155
|
+
validate_entries(body["contents"], "#{location}/contents")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_entries(entries, location)
|
|
160
|
+
entries.each_with_index do |entry, index|
|
|
161
|
+
path = "#{location}/#{index}"
|
|
162
|
+
typed = VALUES.select { |key| entry.key?(key) }
|
|
163
|
+
errors << "#{path}: data entry must contain exactly one typed value" unless typed.size == 1
|
|
164
|
+
validate_entries(entry["valueMap"], "#{path}/valueMap") if entry["valueMap"].is_a?(Array)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Components may precede beginRendering, so catalog checks wait until replay
|
|
169
|
+
# has selected the surface's catalog. Keep overwritten/deleted evidence too.
|
|
170
|
+
def validate_records
|
|
171
|
+
@records.each do |selection, kind, value, location|
|
|
172
|
+
catalog = selection.is_a?(Hash) ? selection["catalog"] : selection
|
|
173
|
+
next unless @catalogs.known?(catalog)
|
|
174
|
+
failures = if kind == :component
|
|
175
|
+
@catalogs.component_errors(catalog, value["type"], value["props"])
|
|
176
|
+
else
|
|
177
|
+
@catalogs.style_errors(catalog, value)
|
|
178
|
+
end
|
|
179
|
+
failures.each { |error| errors << "#{location}: #{error}" }
|
|
180
|
+
if kind == :component && catalog == Catalogs::STANDARD_ID
|
|
181
|
+
references(value, location)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_surfaces
|
|
187
|
+
surfaces.each do |id, surface|
|
|
188
|
+
location = "#{@locations[surface.object_id]}, surface #{id.inspect}"
|
|
189
|
+
if surface["rendered"] && !surface["components"].key?(surface["root"])
|
|
190
|
+
errors << "#{location}/root: rendered root #{surface['root'].inspect} does not exist"
|
|
191
|
+
end
|
|
192
|
+
next unless surface["catalog"] == Catalogs::STANDARD_ID
|
|
193
|
+
graph = {}
|
|
194
|
+
surface["components"].each do |component_id, component|
|
|
195
|
+
path = @component_locations.fetch(surface.object_id, {}).fetch(component_id, location)
|
|
196
|
+
graph[component_id] = references(component, path, report: false)
|
|
197
|
+
graph[component_id].each do |target, reference_path|
|
|
198
|
+
unless surface["components"].key?(target)
|
|
199
|
+
errors << "#{reference_path}: dangling component reference #{target.inspect}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
validate_cycles(graph, location)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def references(component, location, report: true)
|
|
208
|
+
props = component["props"]
|
|
209
|
+
return [] unless props.is_a?(Hash)
|
|
210
|
+
refs = []
|
|
211
|
+
case component["type"]
|
|
212
|
+
when "Row", "Column", "List"
|
|
213
|
+
children = props["children"]
|
|
214
|
+
if children.is_a?(Hash)
|
|
215
|
+
alternatives = %w[explicitList template].select { |key| children.key?(key) }
|
|
216
|
+
if report && alternatives.size != 1
|
|
217
|
+
errors << "#{location}/children: expected exactly one of explicitList or template"
|
|
218
|
+
end
|
|
219
|
+
if children["explicitList"].is_a?(Array)
|
|
220
|
+
children["explicitList"].each_with_index { |id, index| refs << [id, "#{location}/children/explicitList/#{index}"] }
|
|
221
|
+
end
|
|
222
|
+
template = children["template"]
|
|
223
|
+
refs << [template["componentId"], "#{location}/children/template/componentId"] if template.is_a?(Hash) && template.key?("componentId")
|
|
224
|
+
end
|
|
225
|
+
when "Card", "Button"
|
|
226
|
+
refs << [props["child"], "#{location}/child"] if props.key?("child")
|
|
227
|
+
when "Modal"
|
|
228
|
+
%w[entryPointChild contentChild].each { |key| refs << [props[key], "#{location}/#{key}"] if props.key?(key) }
|
|
229
|
+
when "Tabs"
|
|
230
|
+
if props["tabItems"].is_a?(Array)
|
|
231
|
+
props["tabItems"].each_with_index do |tab, index|
|
|
232
|
+
refs << [tab["child"], "#{location}/tabItems/#{index}/child"] if tab.is_a?(Hash) && tab.key?("child")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
refs
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def validate_cycles(graph, location)
|
|
240
|
+
state = {}
|
|
241
|
+
graph.each_key do |start|
|
|
242
|
+
next if state[start]
|
|
243
|
+
stack = [[start, false]]
|
|
244
|
+
until stack.empty?
|
|
245
|
+
id, leaving = stack.pop
|
|
246
|
+
if leaving
|
|
247
|
+
state[id] = :done
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
next if state[id] == :done
|
|
251
|
+
state[id] = :visiting
|
|
252
|
+
stack << [id, true]
|
|
253
|
+
graph.fetch(id, []).reverse_each do |target, path|
|
|
254
|
+
if state[target] == :visiting
|
|
255
|
+
errors << "#{path}: component reference cycle involving #{target.inspect}"
|
|
256
|
+
elsif graph.key?(target) && !state[target]
|
|
257
|
+
stack << [target, false]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def copy(value)
|
|
265
|
+
case value
|
|
266
|
+
when Hash then value.to_h { |key, item| [key, copy(item)] }
|
|
267
|
+
when Array then value.map { |item| copy(item) }
|
|
268
|
+
when String then value.dup
|
|
269
|
+
else value
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Evaluations
|
|
5
|
+
module A2UI
|
|
6
|
+
class Evaluator
|
|
7
|
+
attr_reader :definition
|
|
8
|
+
|
|
9
|
+
def initialize(version: "0.8", catalogs: {})
|
|
10
|
+
raise ArgumentError, "Supported A2UI version is 0.8" unless version == "0.8"
|
|
11
|
+
@catalogs = Catalogs.new(catalogs)
|
|
12
|
+
@definition = { "version" => version.dup.freeze, "catalogs" => @catalogs.definitions }.freeze
|
|
13
|
+
@check = Evaluators.response_components(protocol: "a2ui", version: version) do |components, context|
|
|
14
|
+
document = Document.new(@catalogs).read(components)
|
|
15
|
+
errors = document.errors
|
|
16
|
+
errors += Expectations.new(context.metadata, @catalogs).check(document) if errors.empty?
|
|
17
|
+
Assessment.new(score: errors.empty?, reason: errors.empty? ? "Valid A2UI 0.8 response" : errors.join("; "))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_expectations!(metadata)
|
|
22
|
+
Expectations.new(metadata, @catalogs)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(output, context)
|
|
26
|
+
@check.call(output, context)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module Evaluators
|
|
32
|
+
def self.a2ui(version: "0.8", catalogs: {})
|
|
33
|
+
A2UI::Evaluator.new(version: version, catalogs: catalogs)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Evaluations
|
|
5
|
+
module A2UI
|
|
6
|
+
# Business assertions are question data, so changing them changes the suite fingerprint.
|
|
7
|
+
class Expectations
|
|
8
|
+
def initialize(metadata, catalogs)
|
|
9
|
+
value = metadata.key?(:a2ui) ? metadata[:a2ui] : metadata["a2ui"]
|
|
10
|
+
@surfaces = {}
|
|
11
|
+
return unless metadata.key?(:a2ui) || metadata.key?("a2ui")
|
|
12
|
+
|
|
13
|
+
value = Data.json_value(value)
|
|
14
|
+
fields!(value, %w[surfaces], "metadata.a2ui")
|
|
15
|
+
@surfaces = value.fetch("surfaces", {})
|
|
16
|
+
raise DefinitionError, "metadata.a2ui.surfaces must be a Hash" unless @surfaces.is_a?(Hash)
|
|
17
|
+
@surfaces.each do |id, surface|
|
|
18
|
+
label!(id, "surface ID")
|
|
19
|
+
fields!(surface, %w[catalog root components], "surface #{id}")
|
|
20
|
+
if surface.key?("catalog")
|
|
21
|
+
label!(surface["catalog"], "surface #{id} catalog")
|
|
22
|
+
unless catalogs.known?(catalog_id(surface["catalog"]))
|
|
23
|
+
raise DefinitionError, "Unknown expected A2UI catalog: #{surface['catalog']}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
label!(surface["root"], "surface #{id} root") if surface.key?("root")
|
|
27
|
+
components = surface.fetch("components", {})
|
|
28
|
+
raise DefinitionError, "surface #{id} components must be a Hash" unless components.is_a?(Hash)
|
|
29
|
+
components.each do |component_id, component|
|
|
30
|
+
label!(component_id, "component ID")
|
|
31
|
+
fields!(component, %w[type props], "surface #{id} component #{component_id}")
|
|
32
|
+
label!(component["type"], "component #{component_id} type") if component.key?("type")
|
|
33
|
+
if component.key?("props") && !component["props"].is_a?(Hash)
|
|
34
|
+
raise DefinitionError, "component #{component_id} props must be a Hash"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check(document)
|
|
41
|
+
errors = []
|
|
42
|
+
@surfaces.each do |id, expected|
|
|
43
|
+
path = "surface #{id}"
|
|
44
|
+
actual = document.surfaces[id]
|
|
45
|
+
unless actual && actual["rendered"]
|
|
46
|
+
errors << "#{path}: expected a rendered surface"
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
compare(catalog_id(expected["catalog"]), actual["catalog"], "#{path} catalog", errors) if expected.key?("catalog")
|
|
50
|
+
compare(expected["root"], actual["root"], "#{path} root", errors) if expected.key?("root")
|
|
51
|
+
expected.fetch("components", {}).each do |component_id, component|
|
|
52
|
+
component_path = "#{path} component #{component_id}"
|
|
53
|
+
observed = actual.fetch("components")[component_id]
|
|
54
|
+
unless observed
|
|
55
|
+
errors << "#{component_path}: missing component"
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
compare(component["type"], observed["type"], "#{component_path} type", errors) if component.key?("type")
|
|
59
|
+
compare(component["props"], observed["props"], "#{component_path} props", errors) if component.key?("props")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
errors
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def compare(expected, actual, path, errors)
|
|
68
|
+
if expected.is_a?(Hash) && actual.is_a?(Hash)
|
|
69
|
+
expected.each do |key, value|
|
|
70
|
+
if actual.key?(key)
|
|
71
|
+
compare(value, actual[key], "#{path}.#{key}", errors)
|
|
72
|
+
else
|
|
73
|
+
errors << "#{path}.#{key}: missing property"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
elsif expected != actual
|
|
77
|
+
errors << "#{path}: expected #{expected.inspect}, got #{actual.inspect}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def catalog_id(value)
|
|
82
|
+
value == "standard" ? Catalogs::STANDARD_ID : value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fields!(value, allowed, path)
|
|
86
|
+
raise DefinitionError, "#{path} must be a Hash" unless value.is_a?(Hash)
|
|
87
|
+
unknown = value.keys - allowed
|
|
88
|
+
raise DefinitionError, "#{path}: unknown assertions #{unknown.join(', ')}" unless unknown.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def label!(value, label)
|
|
92
|
+
raise DefinitionError, "#{label} must be a nonempty String" unless value.is_a?(String) && !value.strip.empty?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -81,6 +81,7 @@ module RubyLLM
|
|
|
81
81
|
custom = evaluators.find { |definition| definition.name == metric_name }
|
|
82
82
|
callable = custom ? custom.callable : standard_metric(metric_name)
|
|
83
83
|
next if judge.nil? && callable.is_a?(JudgeMetric)
|
|
84
|
+
callable.validate_expectations!(item.metadata) if callable.is_a?(A2UI::Evaluator)
|
|
84
85
|
Evaluator.new(name: metric_name, callable: callable, threshold: threshold).freeze
|
|
85
86
|
end.freeze
|
|
86
87
|
Question.new(**item.to_h, minimum_scores: immutable_copy(minimum_scores),
|
|
@@ -98,6 +99,10 @@ module RubyLLM
|
|
|
98
99
|
data = data.merge(api_interactions: item.api_interactions.to_h) if item.api_interactions
|
|
99
100
|
skipped = item.minimum_scores.keys - item.evaluators.map(&:name)
|
|
100
101
|
data = data.merge(skipped_metrics: skipped) unless skipped.empty?
|
|
102
|
+
ui = item.evaluators.filter_map do |evaluator|
|
|
103
|
+
[evaluator.name, evaluator.callable.definition] if evaluator.callable.is_a?(A2UI::Evaluator)
|
|
104
|
+
end.to_h
|
|
105
|
+
data = data.merge(a2ui_evaluators: ui) unless ui.empty?
|
|
101
106
|
definition_value(data)
|
|
102
107
|
end }
|
|
103
108
|
Digest::SHA256.hexdigest(JSON.generate(Data.canonical(definition)))
|
|
@@ -111,6 +116,8 @@ module RubyLLM
|
|
|
111
116
|
Evaluators.public_send(name)
|
|
112
117
|
when "action_group"
|
|
113
118
|
Evaluators.action_group
|
|
119
|
+
when "a2ui"
|
|
120
|
+
@a2ui_evaluator ||= Evaluators.a2ui
|
|
114
121
|
else
|
|
115
122
|
raise DefinitionError, "unknown metric: #{name}; register it with evaluate"
|
|
116
123
|
end
|
data/lib/ruby_llm/evaluations.rb
CHANGED
|
@@ -8,6 +8,10 @@ require_relative "evaluations/response_component_capture"
|
|
|
8
8
|
require_relative "evaluations/response_component_tool"
|
|
9
9
|
require_relative "evaluations/judge_metric"
|
|
10
10
|
require_relative "evaluations/evaluators"
|
|
11
|
+
require_relative "evaluations/a2ui/catalogs"
|
|
12
|
+
require_relative "evaluations/a2ui/document"
|
|
13
|
+
require_relative "evaluations/a2ui/expectations"
|
|
14
|
+
require_relative "evaluations/a2ui/evaluator"
|
|
11
15
|
require_relative "evaluations/api_replay"
|
|
12
16
|
require_relative "evaluations/suite"
|
|
13
17
|
require_relative "evaluations/response"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_llm-agent-evaluations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- RubyLLM Evaluations contributors
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-13 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -29,6 +29,20 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: json_schemer
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.0'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '2.0'
|
|
32
46
|
- !ruby/object:Gem::Dependency
|
|
33
47
|
name: webrick
|
|
34
48
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -89,6 +103,14 @@ files:
|
|
|
89
103
|
- examples/support_eval.rb
|
|
90
104
|
- exe/ruby_llm-agent-evaluations
|
|
91
105
|
- lib/ruby_llm/evaluations.rb
|
|
106
|
+
- lib/ruby_llm/evaluations/a2ui/assets/LICENSE
|
|
107
|
+
- lib/ruby_llm/evaluations/a2ui/assets/PROVENANCE.json
|
|
108
|
+
- lib/ruby_llm/evaluations/a2ui/assets/v0_8/server_to_client.json
|
|
109
|
+
- lib/ruby_llm/evaluations/a2ui/assets/v0_8/standard_catalog_definition.json
|
|
110
|
+
- lib/ruby_llm/evaluations/a2ui/catalogs.rb
|
|
111
|
+
- lib/ruby_llm/evaluations/a2ui/document.rb
|
|
112
|
+
- lib/ruby_llm/evaluations/a2ui/evaluator.rb
|
|
113
|
+
- lib/ruby_llm/evaluations/a2ui/expectations.rb
|
|
92
114
|
- lib/ruby_llm/evaluations/action_group.rb
|
|
93
115
|
- lib/ruby_llm/evaluations/actions.rb
|
|
94
116
|
- lib/ruby_llm/evaluations/adapters.rb
|