activeagent 1.9.6 → 1.9.8
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 +612 -28
- data/README.md +9 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +50 -7
- data/lib/active_agent/concerns/tooling.rb +11 -0
- data/lib/active_agent/evals/capture.rb +286 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/model_capabilities.rb +1 -1
- data/lib/active_agent/providers/_base_provider.rb +18 -2
- data/lib/active_agent/providers/anthropic_provider.rb +10 -0
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/common/usage.rb +6 -1
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
- data/lib/active_agent/providers/requesty_provider.rb +8 -0
- data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
- data/lib/active_agent/providers/runcrate/_types.rb +17 -0
- data/lib/active_agent/providers/runcrate/options.rb +39 -0
- data/lib/active_agent/providers/runcrate_provider.rb +72 -0
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +27 -5
- data/lib/tasks/activeagent_tasks.rake +0 -4
|
@@ -6,8 +6,9 @@ module ActiveAgent
|
|
|
6
6
|
module Evals
|
|
7
7
|
# A catalog of evaluation scenarios: the products an app evaluates, each
|
|
8
8
|
# with the named sets of scenarios it is tested with. One YAML document,
|
|
9
|
-
# or several layered like Suite's: a later document's product, set
|
|
10
|
-
# scenario with a key already present replaces it, a new key
|
|
9
|
+
# or several layered like Suite's: a later document's product, set,
|
|
10
|
+
# scenario or criterion with a key already present replaces it, a new key
|
|
11
|
+
# is appended.
|
|
11
12
|
#
|
|
12
13
|
# catalog: support_desk
|
|
13
14
|
# name: Support Desk
|
|
@@ -16,11 +17,14 @@ module ActiveAgent
|
|
|
16
17
|
# - key: triage
|
|
17
18
|
# name: Triage agent
|
|
18
19
|
# agent: TriageAgent # the agent class, or a dashboard agent's name
|
|
20
|
+
# criteria: { shared: [] } # optional; named criteria lists, as a Suite's
|
|
21
|
+
# pass: { min_pass_rate: 0.8 } # optional; the product's bars (PassThresholds)
|
|
19
22
|
# sets:
|
|
20
23
|
# - key: smoke
|
|
21
24
|
# name: Smoke
|
|
22
25
|
# judge: { kind: rules } # optional; an evaluation's judge_kind and judge_model
|
|
23
26
|
# criteria: [] # optional; an evaluation's criteria
|
|
27
|
+
# pass: { gated: false } # optional; the set's own bars
|
|
24
28
|
# scenarios:
|
|
25
29
|
# - key: refund_request
|
|
26
30
|
# prompt: A customer asks for a refund on order 1042.
|
|
@@ -47,12 +51,12 @@ module ActiveAgent
|
|
|
47
51
|
|
|
48
52
|
# The keys a product, set or scenario may carry. Anything else is kept
|
|
49
53
|
# under `metadata` so a document round-trips.
|
|
50
|
-
PRODUCT_KEYS = %w[key name description agent sets].freeze
|
|
51
|
-
SET_KEYS = %w[key name description judge criteria scenarios].freeze
|
|
52
|
-
SCENARIO_KEYS =
|
|
54
|
+
PRODUCT_KEYS = %w[key name description agent criteria pass sets].freeze
|
|
55
|
+
SET_KEYS = %w[key name description judge criteria pass scenarios].freeze
|
|
56
|
+
SCENARIO_KEYS = Scenario::DOCUMENT_KEYS
|
|
53
57
|
CATALOG_KEYS = %w[catalog name description products suite groups].freeze
|
|
54
58
|
|
|
55
|
-
Product = Struct.new(:key, :name, :description, :agent, :metadata, :sets, :position, keyword_init: true) do
|
|
59
|
+
Product = Struct.new(:key, :name, :description, :agent, :criteria, :pass, :metadata, :sets, :position, keyword_init: true) do
|
|
56
60
|
def set(key)
|
|
57
61
|
sets.find { |set| set.key == key.to_s }
|
|
58
62
|
end
|
|
@@ -62,7 +66,7 @@ module ActiveAgent
|
|
|
62
66
|
end
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
Set = Struct.new(:key, :name, :description, :judge, :criteria, :metadata, :scenarios, :position, keyword_init: true)
|
|
69
|
+
Set = Struct.new(:key, :name, :description, :judge, :criteria, :pass, :metadata, :scenarios, :position, keyword_init: true)
|
|
66
70
|
|
|
67
71
|
attr_reader :key, :name, :description, :metadata, :products
|
|
68
72
|
|
|
@@ -114,14 +118,21 @@ module ActiveAgent
|
|
|
114
118
|
# Scenarios across the catalog, narrowed by product key, set key,
|
|
115
119
|
# scenario keys, or any of them; `production_only` scenarios are
|
|
116
120
|
# dropped unless `include_production_only` is true. Each scenario's
|
|
117
|
-
# group is its set's key and its group_name the set's name.
|
|
118
|
-
|
|
121
|
+
# group is its set's key and its group_name the set's name. With
|
|
122
|
+
# `strict`, every key in `keys` must name a scenario of the catalog (see
|
|
123
|
+
# Suite.select), and a key outside the chosen product or set selects
|
|
124
|
+
# nothing.
|
|
125
|
+
def scenarios(product: nil, set: nil, keys: nil, include_production_only: true, strict: false)
|
|
119
126
|
selected = products
|
|
120
127
|
selected = selected.select { |entry| entry.key == product.to_s } if product.present?
|
|
121
128
|
selected = selected.flat_map(&:sets)
|
|
122
129
|
selected = selected.select { |entry| entry.key == set.to_s } if set.present?
|
|
123
130
|
selected = selected.flat_map(&:scenarios)
|
|
124
|
-
|
|
131
|
+
if strict
|
|
132
|
+
selected = Suite.select(selected, keys: keys, known: all_scenarios, name: key)
|
|
133
|
+
elsif keys.present?
|
|
134
|
+
selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) }
|
|
135
|
+
end
|
|
125
136
|
selected = selected.reject(&:production_only?) unless include_production_only
|
|
126
137
|
selected
|
|
127
138
|
end
|
|
@@ -136,16 +147,24 @@ module ActiveAgent
|
|
|
136
147
|
|
|
137
148
|
# One product's sets as a Suite (every set a group), or one set alone,
|
|
138
149
|
# for code that takes a suite: `Runner.new(scenarios: catalog.suite_for(:triage).scenarios, ...)`.
|
|
150
|
+
# The product's `pass` is the suite's and each set's its group's.
|
|
139
151
|
def suite_for(product_key, set_key = nil)
|
|
140
152
|
found = product(product_key)
|
|
141
153
|
sets = set_key ? [ set(product_key, set_key) ] : found.sets
|
|
142
154
|
Suite.new([ {
|
|
143
155
|
"suite" => [ key, found.key, set_key ].compact.join("/"),
|
|
144
156
|
"description" => found.description || description,
|
|
157
|
+
"criteria" => found.criteria,
|
|
158
|
+
"pass" => found.pass,
|
|
145
159
|
"groups" => sets.map { |entry| set_document(entry) }
|
|
146
160
|
} ])
|
|
147
161
|
end
|
|
148
162
|
|
|
163
|
+
# The bars a product, or one of its sets, declares (PassThresholds).
|
|
164
|
+
def pass_thresholds(product_key, set_key = nil)
|
|
165
|
+
suite_for(product_key, set_key).pass_thresholds
|
|
166
|
+
end
|
|
167
|
+
|
|
149
168
|
# The canonical document: keys in a fixed order, nothing empty, so the
|
|
150
169
|
# same content always reads the same.
|
|
151
170
|
def to_h
|
|
@@ -182,8 +201,10 @@ module ActiveAgent
|
|
|
182
201
|
"key" => product_key,
|
|
183
202
|
"name" => document["name"].presence || product_key,
|
|
184
203
|
"description" => document["description"],
|
|
204
|
+
"criteria" => document["criteria"],
|
|
205
|
+
"pass" => document["pass"],
|
|
185
206
|
"sets" => Array(document["groups"])
|
|
186
|
-
} ]
|
|
207
|
+
}.compact ]
|
|
187
208
|
}.compact
|
|
188
209
|
end
|
|
189
210
|
|
|
@@ -196,16 +217,70 @@ module ActiveAgent
|
|
|
196
217
|
existing = products.find { |product| product["key"] == incoming["key"].to_s }
|
|
197
218
|
if existing
|
|
198
219
|
%w[name description agent].each { |field| existing[field] = incoming[field] if incoming[field].present? }
|
|
220
|
+
merge_pass(existing, incoming["pass"], "product #{existing['key'].inspect}")
|
|
199
221
|
existing.merge!(incoming.except(*PRODUCT_KEYS))
|
|
222
|
+
merge_criteria_lists(existing, incoming)
|
|
200
223
|
merge_sets(existing, Array(incoming["sets"]))
|
|
201
224
|
else
|
|
202
|
-
products << incoming.merge("key" => incoming["key"].to_s, "sets" => [])
|
|
225
|
+
products << incoming.merge("key" => incoming["key"].to_s, "criteria" => {}, "sets" => [])
|
|
226
|
+
.tap { |product| merge_criteria_lists(product, incoming) }
|
|
203
227
|
.tap { |product| merge_sets(product, Array(incoming["sets"])) }
|
|
204
228
|
end
|
|
205
229
|
end
|
|
206
230
|
end
|
|
207
231
|
end
|
|
208
232
|
|
|
233
|
+
# Layers a product document's named criteria lists over the product's:
|
|
234
|
+
# by name, and within a name by criterion key.
|
|
235
|
+
def merge_criteria_lists(product, incoming)
|
|
236
|
+
criteria_lists(incoming).each do |list_name, list|
|
|
237
|
+
merge_criteria(product["criteria"][list_name] ||= [], list, "list #{list_name.inspect} of product #{product['key'].inspect}")
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A product document's `criteria` as `name => list`; a bare list is the
|
|
242
|
+
# one list named "default", and an empty one declares no list.
|
|
243
|
+
def criteria_lists(product)
|
|
244
|
+
value = product["criteria"]
|
|
245
|
+
return {} if value.nil? || value == []
|
|
246
|
+
|
|
247
|
+
value = { "default" => value } if value.is_a?(Array)
|
|
248
|
+
unless value.is_a?(Hash)
|
|
249
|
+
raise InvalidDocument, "the criteria of product #{product['key'].inspect} are not a list or a mapping of lists"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
value.to_h do |list_name, list|
|
|
253
|
+
raise InvalidDocument, "criteria list #{list_name.inspect} of product #{product['key'].inspect} is not a list" unless list.is_a?(Array)
|
|
254
|
+
|
|
255
|
+
[ list_name.to_s, list ]
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Layers `incoming` criteria over `existing` by key: a criterion with a
|
|
260
|
+
# key already present replaces it in place and a new key is appended;
|
|
261
|
+
# an empty or missing `incoming` declares none. A criterion without a
|
|
262
|
+
# key is appended once, when `allow_keyless` is true, and refused
|
|
263
|
+
# otherwise.
|
|
264
|
+
def merge_criteria(existing, incoming, owner, allow_keyless: false)
|
|
265
|
+
return if incoming.blank?
|
|
266
|
+
raise InvalidDocument, "the criteria of #{owner} are not a list" unless incoming.is_a?(Array)
|
|
267
|
+
|
|
268
|
+
incoming.each do |criterion|
|
|
269
|
+
raise InvalidDocument, "a criterion in #{owner} is not a mapping" unless criterion.is_a?(Hash)
|
|
270
|
+
|
|
271
|
+
criterion = criterion.deep_stringify_keys
|
|
272
|
+
if criterion["key"].present?
|
|
273
|
+
criterion["key"] = criterion["key"].to_s
|
|
274
|
+
index = existing.index { |entry| entry["key"] == criterion["key"] }
|
|
275
|
+
index ? existing[index] = criterion : existing << criterion
|
|
276
|
+
elsif allow_keyless
|
|
277
|
+
existing << criterion unless existing.include?(criterion)
|
|
278
|
+
else
|
|
279
|
+
raise InvalidDocument, "a criterion in #{owner} has no key"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
209
284
|
def merge_sets(product, incoming_sets)
|
|
210
285
|
incoming_sets.each do |incoming|
|
|
211
286
|
incoming = incoming.to_h.deep_stringify_keys
|
|
@@ -213,16 +288,31 @@ module ActiveAgent
|
|
|
213
288
|
|
|
214
289
|
existing = product["sets"].find { |set| set["key"] == incoming["key"].to_s }
|
|
215
290
|
if existing
|
|
216
|
-
%w[name description judge
|
|
291
|
+
%w[name description judge].each { |field| existing[field] = incoming[field] if incoming[field].present? }
|
|
292
|
+
merge_pass(existing, incoming["pass"], "set #{existing['key'].inspect} of product #{product['key'].inspect}")
|
|
217
293
|
existing.merge!(incoming.except(*SET_KEYS))
|
|
294
|
+
merge_set_criteria(existing, incoming, product)
|
|
218
295
|
merge_scenarios(existing, Array(incoming["scenarios"]))
|
|
219
296
|
else
|
|
220
|
-
product["sets"] << incoming.merge("key" => incoming["key"].to_s, "scenarios" => [])
|
|
297
|
+
product["sets"] << incoming.merge("key" => incoming["key"].to_s, "criteria" => [], "scenarios" => [])
|
|
298
|
+
.tap { |set| merge_set_criteria(set, incoming, product) }
|
|
221
299
|
.tap { |set| merge_scenarios(set, Array(incoming["scenarios"])) }
|
|
222
300
|
end
|
|
223
301
|
end
|
|
224
302
|
end
|
|
225
303
|
|
|
304
|
+
# A later document's `pass` layers over the existing one key by key.
|
|
305
|
+
def merge_pass(existing, incoming, place)
|
|
306
|
+
incoming = pass_block(incoming, place)
|
|
307
|
+
existing["pass"] = pass_block(existing["pass"], place).merge(incoming) if incoming.any?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Layers a set document's `criteria` over the set's by key; a set keeps
|
|
311
|
+
# a criterion without a key.
|
|
312
|
+
def merge_set_criteria(set, incoming, product)
|
|
313
|
+
merge_criteria(set["criteria"], incoming["criteria"], "set #{set['key'].inspect} of product #{product['key'].inspect}", allow_keyless: true)
|
|
314
|
+
end
|
|
315
|
+
|
|
226
316
|
def merge_scenarios(set, incoming_scenarios)
|
|
227
317
|
incoming_scenarios.each do |scenario|
|
|
228
318
|
scenario = scenario.to_h.deep_stringify_keys
|
|
@@ -237,39 +327,67 @@ module ActiveAgent
|
|
|
237
327
|
|
|
238
328
|
def build_products(documents)
|
|
239
329
|
documents.each_with_index.map do |product, position|
|
|
330
|
+
sets = product["sets"].each_with_index.map { |set, set_position| build_set(set, set_position, product["key"]) }
|
|
240
331
|
Product.new(
|
|
241
332
|
key: product["key"],
|
|
242
333
|
name: product["name"].presence || product["key"],
|
|
243
334
|
description: product["description"].presence,
|
|
244
335
|
agent: product["agent"].presence&.to_s,
|
|
336
|
+
criteria: product["criteria"],
|
|
337
|
+
pass: validated_pass(product["pass"], PassThresholds::SUITE_KEYS, "product #{product['key'].inspect}"),
|
|
245
338
|
metadata: product.except(*PRODUCT_KEYS),
|
|
246
339
|
position: position,
|
|
247
|
-
sets:
|
|
340
|
+
sets: sets
|
|
248
341
|
)
|
|
249
342
|
end
|
|
250
343
|
end
|
|
251
344
|
|
|
252
|
-
def build_set(set, position)
|
|
345
|
+
def build_set(set, position, product_key)
|
|
253
346
|
Set.new(
|
|
254
347
|
key: set["key"],
|
|
255
348
|
name: set["name"].presence || set["key"],
|
|
256
349
|
description: set["description"].presence,
|
|
257
350
|
judge: (set["judge"] || {}).to_h.stringify_keys,
|
|
258
|
-
criteria:
|
|
351
|
+
criteria: set["criteria"],
|
|
352
|
+
pass: validated_pass(set["pass"], PassThresholds::GROUP_KEYS, "set #{set['key'].inspect} of product #{product_key.inspect}"),
|
|
259
353
|
metadata: set.except(*SET_KEYS),
|
|
260
354
|
position: position,
|
|
261
355
|
scenarios: set["scenarios"].each_with_index.map do |entry, index|
|
|
262
|
-
Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"]
|
|
356
|
+
Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"],
|
|
357
|
+
metadata: entry.except(*SCENARIO_KEYS))
|
|
263
358
|
end
|
|
264
359
|
)
|
|
265
360
|
end
|
|
266
361
|
|
|
362
|
+
# A `pass` block as a string-keyed Hash, {} when absent, checked the
|
|
363
|
+
# way PassThresholds reads it (`allowed` is SUITE_KEYS for a product,
|
|
364
|
+
# GROUP_KEYS for a set) so a block the suite would refuse fails the
|
|
365
|
+
# catalog as it loads rather than the first run that asks for it; a
|
|
366
|
+
# refusal names the product or set the block sits on.
|
|
367
|
+
def validated_pass(value, allowed, place)
|
|
368
|
+
pass = pass_block(value, place)
|
|
369
|
+
PassThresholds.validate(pass, allowed, place)
|
|
370
|
+
pass
|
|
371
|
+
rescue ArgumentError => e
|
|
372
|
+
raise InvalidDocument, e.message
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# A `pass` block as a string-keyed Hash, {} when absent; anything but
|
|
376
|
+
# a mapping is refused naming the product or set it sits on.
|
|
377
|
+
def pass_block(value, place)
|
|
378
|
+
PassThresholds.pass_block(value, place)
|
|
379
|
+
rescue ArgumentError => e
|
|
380
|
+
raise InvalidDocument, e.message
|
|
381
|
+
end
|
|
382
|
+
|
|
267
383
|
def product_document(product)
|
|
268
384
|
compact({
|
|
269
385
|
"key" => product.key,
|
|
270
386
|
"name" => product.name,
|
|
271
387
|
"description" => product.description,
|
|
272
388
|
"agent" => product.agent,
|
|
389
|
+
"criteria" => product.criteria,
|
|
390
|
+
"pass" => product.pass,
|
|
273
391
|
"sets" => product.sets.map { |set| set_document(set) }
|
|
274
392
|
}.merge(product.metadata))
|
|
275
393
|
end
|
|
@@ -281,6 +399,7 @@ module ActiveAgent
|
|
|
281
399
|
"description" => set.description,
|
|
282
400
|
"judge" => set.judge,
|
|
283
401
|
"criteria" => set.criteria,
|
|
402
|
+
"pass" => set.pass,
|
|
284
403
|
"scenarios" => set.scenarios.map { |scenario| scenario_document(scenario) }
|
|
285
404
|
}.merge(set.metadata))
|
|
286
405
|
end
|
|
@@ -294,7 +413,19 @@ module ActiveAgent
|
|
|
294
413
|
"tags" => scenario.tags,
|
|
295
414
|
"params" => scenario.params,
|
|
296
415
|
"production_only" => (true if scenario.production_only?)
|
|
297
|
-
})
|
|
416
|
+
}).merge(canonical_metadata(scenario.metadata.to_h.deep_stringify_keys.except(*SCENARIO_KEYS)))
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# JSON object storage may reorder keys; array order remains meaningful.
|
|
420
|
+
def canonical_metadata(value)
|
|
421
|
+
case value
|
|
422
|
+
when Hash
|
|
423
|
+
value.keys.sort.to_h { |key| [ key, canonical_metadata(value[key]) ] }
|
|
424
|
+
when Array
|
|
425
|
+
value.map { |entry| canonical_metadata(entry) }
|
|
426
|
+
else
|
|
427
|
+
value
|
|
428
|
+
end
|
|
298
429
|
end
|
|
299
430
|
|
|
300
431
|
def compact(hash)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# A report's pass gates as one check with the message a failing
|
|
6
|
+
# assertion prints, so TestHelper#assert_meets_pass_gates and the RSpec
|
|
7
|
+
# matcher (`active_agent/evals/rspec`) fail with the same text: how many
|
|
8
|
+
# gates are not met, each of them as a sentence, then the failures to
|
|
9
|
+
# read under a heading per group.
|
|
10
|
+
#
|
|
11
|
+
# check = ActiveAgent::Evals::GateCheck.new(report)
|
|
12
|
+
# check.met? # => false
|
|
13
|
+
# puts check.message
|
|
14
|
+
# # 2 of 5 pass gates not met
|
|
15
|
+
# # - Order support · gpt-5-mini: 1/2 (50%) passed, below 100%
|
|
16
|
+
# # - All · gpt-5-mini: 3/4 (75%) passed, below 80%
|
|
17
|
+
# #
|
|
18
|
+
# # Order support failures:
|
|
19
|
+
# # - `order_status` on `gpt-5-mini`: low quality — Scored 50% against a pass threshold of 70%, weakest on response_present (100%).
|
|
20
|
+
class GateCheck
|
|
21
|
+
# @param report [Report] the report whose gates are checked
|
|
22
|
+
# @param thresholds [PassThresholds, nil] the bars to hold the report
|
|
23
|
+
# to; without them Report#gates decides, taking the report's own,
|
|
24
|
+
# else the defaults. Only their `min_pass_rate` and `gated` apply:
|
|
25
|
+
# whether an answer passed was settled at the threshold the Runner
|
|
26
|
+
# scored it at, so a different `threshold` here changes nothing.
|
|
27
|
+
def initialize(report, thresholds = nil)
|
|
28
|
+
@report = report
|
|
29
|
+
@bars = [ thresholds ].compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether every gate is met.
|
|
33
|
+
def met?
|
|
34
|
+
@report.passed?(*@bars)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Every gate, in Report#gates order.
|
|
38
|
+
def gates
|
|
39
|
+
@report.gates(*@bars)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The gates that are not met, in gates order.
|
|
43
|
+
def unmet
|
|
44
|
+
gates.reject(&:met?)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The text a failing assertion prints. The first line counts the gates
|
|
48
|
+
# not met, or is the one line "every pass gate met" when none is; then
|
|
49
|
+
# each unmet gate as Gate#to_s; then, after a blank line, the failures
|
|
50
|
+
# to read: for each group with an unmet gate, in gates order, "<group>
|
|
51
|
+
# failures:" over Report#failures_markdown for that group. An unmet All
|
|
52
|
+
# row adds the failures it counts, those in gated groups or in no
|
|
53
|
+
# group: under "Failures:" when no group row is unmet, else the ones
|
|
54
|
+
# outside the groups already listed under "Other failures:", when there
|
|
55
|
+
# are any. An ungated group's failures count toward no gate and are
|
|
56
|
+
# never listed.
|
|
57
|
+
def message
|
|
58
|
+
all = gates
|
|
59
|
+
return "no result reached a pass gate; the run evaluated nothing" if all.empty?
|
|
60
|
+
|
|
61
|
+
unmet = all.reject(&:met?)
|
|
62
|
+
return "every pass gate met" if unmet.empty?
|
|
63
|
+
|
|
64
|
+
lines = [ "#{unmet.size} of #{all.size} pass gates not met", *unmet.map { |gate| "- #{gate}" } ]
|
|
65
|
+
grouped = unmet.reject { |gate| gate.group.nil? }.uniq(&:group)
|
|
66
|
+
grouped.each { |gate| lines << "" << "#{gate.name} failures:" << @report.failures_markdown(group: gate.group) }
|
|
67
|
+
if unmet.any? { |gate| gate.group.nil? }
|
|
68
|
+
ungated = all.reject(&:gated).map(&:group)
|
|
69
|
+
listed = grouped.map(&:group)
|
|
70
|
+
others = @report.failures.reject { |result| (ungated + listed).include?(result.scenario.group) }
|
|
71
|
+
heading = listed.empty? ? "Failures:" : "Other failures:"
|
|
72
|
+
lines << "" << heading << others.map { |result| @report.failure_markdown(result) }.join("\n") if others.any?
|
|
73
|
+
end
|
|
74
|
+
lines.join("\n")
|
|
75
|
+
end
|
|
76
|
+
alias to_s message
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# The bars a suite declares for a run: the score every answer must reach
|
|
6
|
+
# (the Runner's and the Report's threshold), the share of a model's
|
|
7
|
+
# answers that must pass, and the groups whose results are reported but
|
|
8
|
+
# not gated. Report#gates compares each group's pass rate against them.
|
|
9
|
+
#
|
|
10
|
+
# suite: support_desk
|
|
11
|
+
# pass:
|
|
12
|
+
# threshold: 0.7
|
|
13
|
+
# min_pass_rate: 0.8
|
|
14
|
+
# groups:
|
|
15
|
+
# - key: orders
|
|
16
|
+
# pass: { min_pass_rate: 1.0 }
|
|
17
|
+
# - key: experimental
|
|
18
|
+
# pass: { gated: false }
|
|
19
|
+
#
|
|
20
|
+
# A group's `min_pass_rate` falls back to the suite's, and the suite's to
|
|
21
|
+
# DEFAULT_MIN_PASS_RATE; a group is gated unless it says `gated: false`.
|
|
22
|
+
# Layered documents override key by key at both levels, so a document
|
|
23
|
+
# that sets one group's bar leaves the suite's threshold and the other
|
|
24
|
+
# groups alone.
|
|
25
|
+
class PassThresholds
|
|
26
|
+
DEFAULT_MIN_PASS_RATE = 1.0
|
|
27
|
+
SUITE_KEYS = %w[threshold min_pass_rate].freeze
|
|
28
|
+
GROUP_KEYS = %w[min_pass_rate gated].freeze
|
|
29
|
+
|
|
30
|
+
# Layers each document's top-level `pass` and each group's `pass`
|
|
31
|
+
# (groups under `groups`, keyed by their `key`), later documents
|
|
32
|
+
# winning key by key. A group without a key has no scenarios to gate,
|
|
33
|
+
# so a `pass` block on one is refused.
|
|
34
|
+
#
|
|
35
|
+
# @param documents [Array<Hash>] parsed YAML documents, base first
|
|
36
|
+
def self.from_documents(documents)
|
|
37
|
+
suite = {}
|
|
38
|
+
groups = {}
|
|
39
|
+
Array(documents).each do |document|
|
|
40
|
+
document = document.to_h.deep_stringify_keys
|
|
41
|
+
suite.merge!(pass_block(document["pass"], "the suite"))
|
|
42
|
+
Array(document["groups"]).each do |group|
|
|
43
|
+
group = group.to_h.deep_stringify_keys
|
|
44
|
+
key = group["key"].to_s
|
|
45
|
+
raise ArgumentError, "\"pass\" of a group without a key gates nothing" if key.empty? && !group["pass"].nil?
|
|
46
|
+
|
|
47
|
+
groups[key] = groups.fetch(key, {}).merge(pass_block(group["pass"], "group #{key}"))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
new(suite: suite, groups: groups)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.default
|
|
54
|
+
new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A `pass` block as a string-keyed Hash; nothing declared reads as {},
|
|
58
|
+
# and anything but a mapping raises ArgumentError naming `place`.
|
|
59
|
+
def self.pass_block(value, place)
|
|
60
|
+
return {} if value.nil?
|
|
61
|
+
raise ArgumentError, "\"pass\" of #{place} must be a mapping, got #{value.inspect}" unless value.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
value.deep_stringify_keys
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `pass` as a string-keyed Hash of the `allowed` keys (SUITE_KEYS or
|
|
67
|
+
# GROUP_KEYS), each refusal naming `place` — the suite, a group, or
|
|
68
|
+
# whatever a caller holds a block for: a bar is a number from 0 to 1,
|
|
69
|
+
# kept as a Float so `1` and `1.0` read, serialize and hash the same,
|
|
70
|
+
# and `gated` is true or false.
|
|
71
|
+
def self.validate(pass, allowed, place)
|
|
72
|
+
pass_block(pass, place).to_h do |key, value|
|
|
73
|
+
raise ArgumentError, "unknown key #{key.inspect} in \"pass\" of #{place}; expected one of #{allowed.join(', ')}" unless allowed.include?(key)
|
|
74
|
+
|
|
75
|
+
if key == "gated"
|
|
76
|
+
raise ArgumentError, "\"gated\" in \"pass\" of #{place} must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
|
|
77
|
+
|
|
78
|
+
[ key, value ]
|
|
79
|
+
elsif value.is_a?(Numeric) && value.finite? && value.between?(0, 1)
|
|
80
|
+
[ key, value.to_f ]
|
|
81
|
+
else
|
|
82
|
+
raise ArgumentError, "#{key.inspect} in \"pass\" of #{place} must be a number from 0 to 1, got #{value.inspect}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param suite [Hash] the suite's bars, `threshold` and `min_pass_rate`
|
|
88
|
+
# @param groups [Hash{String=>Hash}] each group's bars by group key,
|
|
89
|
+
# `min_pass_rate` and `gated`
|
|
90
|
+
def initialize(suite: {}, groups: {})
|
|
91
|
+
@suite = self.class.validate(suite, SUITE_KEYS, "the suite")
|
|
92
|
+
@groups = groups.to_h.deep_stringify_keys.filter_map do |key, pass|
|
|
93
|
+
declared = self.class.validate(pass, GROUP_KEYS, "group #{key}")
|
|
94
|
+
[ key.to_s, declared ] if declared.any?
|
|
95
|
+
end.to_h
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The score every answer must reach, 0..1.
|
|
99
|
+
def threshold
|
|
100
|
+
(@suite["threshold"] || PASS_THRESHOLD).to_f
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The share of a model's answers that must pass, 0..1: the group's own
|
|
104
|
+
# bar when it declared one, else the suite's, else DEFAULT_MIN_PASS_RATE.
|
|
105
|
+
def min_pass_rate(group = nil)
|
|
106
|
+
own = @groups.dig(group.to_s, "min_pass_rate") unless group.nil?
|
|
107
|
+
(own || @suite["min_pass_rate"] || DEFAULT_MIN_PASS_RATE).to_f
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# False only for a group that declared `gated: false`; nil and unknown
|
|
111
|
+
# groups are gated.
|
|
112
|
+
def gated?(group)
|
|
113
|
+
return true if group.nil?
|
|
114
|
+
|
|
115
|
+
@groups.dig(group.to_s, "gated") != false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The bars in effect, with each group's declared keys only.
|
|
119
|
+
def to_h
|
|
120
|
+
{
|
|
121
|
+
"threshold" => threshold,
|
|
122
|
+
"min_pass_rate" => min_pass_rate,
|
|
123
|
+
"groups" => @groups.transform_values(&:dup)
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ==(other)
|
|
128
|
+
other.is_a?(PassThresholds) && to_h == other.to_h
|
|
129
|
+
end
|
|
130
|
+
alias eql? ==
|
|
131
|
+
|
|
132
|
+
def hash
|
|
133
|
+
to_h.hash
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# Regexes for a figure an answer is expected to state, built from the data
|
|
6
|
+
# the scenario was seeded with: a count ("3 of 4 tickets") or a share of
|
|
7
|
+
# a total ("75%"). Each is a String that drops into a suite's
|
|
8
|
+
# `expect.contains` / `not_contains` or a `contains` criterion, which
|
|
9
|
+
# Scorer.matches_pattern? tries as a substring and then as a regex:
|
|
10
|
+
#
|
|
11
|
+
# "expect" => { "contains" => [ Patterns.number(3), Patterns.share(3, 4) ] }
|
|
12
|
+
module Patterns
|
|
13
|
+
MONTHS = %w[january february march april may june july august september october november december].freeze
|
|
14
|
+
|
|
15
|
+
# `text` as a pattern that matches it in any case, each letter a
|
|
16
|
+
# character class: any_case("Oct.") is "[Oo][Cc][Tt]\\.".
|
|
17
|
+
def self.any_case(text)
|
|
18
|
+
text.chars.map { |char| char.match?(/[a-z]/i) ? "[#{char.upcase}#{char.downcase}]" : Regexp.escape(char) }.join
|
|
19
|
+
end
|
|
20
|
+
private_class_method :any_case
|
|
21
|
+
|
|
22
|
+
# A day of the month written beside its month, in any case: "Oct 12",
|
|
23
|
+
# "Sept. 12" and "October 12" before the figure, "12 Oct" and "12th of
|
|
24
|
+
# October" after it. A lookbehind takes alternatives of one length only,
|
|
25
|
+
# so the spellings before the figure are one lookbehind per length, and
|
|
26
|
+
# each letter in it is a character class rather than `(?i:...)`: the
|
|
27
|
+
# dashes make the pattern UTF-8, where case folding lets "st" match the
|
|
28
|
+
# one-character ligature "st", and Ruby 3.2 refuses a lookbehind whose
|
|
29
|
+
# length that leaves open. "may" is left out after the figure, where it
|
|
30
|
+
# is usually the verb.
|
|
31
|
+
MONTH_BEFORE = MONTHS.flat_map { |month| [ month, month[0, 3], "#{month[0, 3]}." ] }.push("sept", "sept.").uniq
|
|
32
|
+
.group_by(&:length).sort.map { |_length, spellings| "(?<!#{spellings.map { |spelling| "\\b#{any_case("#{spelling} ")}" }.join('|')})" }
|
|
33
|
+
.join.freeze
|
|
34
|
+
MONTH_AFTER = (MONTHS - %w[may]).map { |month| "#{month[0, 3]}(?:#{Regexp.escape(month[3..])})?" }.push("sept").join("|").freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# The integer `count` as a figure on its own: number(10) matches
|
|
39
|
+
# "10 sessions", "(10)" and "10." at a sentence end, not "2026-10-01",
|
|
40
|
+
# "10/1", "10:00", "1-10" or "1–10", "110", "10.5", "10,000", "10%",
|
|
41
|
+
# "Oct 10" or "10 October". A ratio such as "10/22" does not match
|
|
42
|
+
# either, because "10/12" reads as a date as often as a count; expect
|
|
43
|
+
# "10 of 22". `count` is read as a whole number from its text, so 3 and
|
|
44
|
+
# "3" work and 1.5, 10.0, "a" or a negative count raise ArgumentError.
|
|
45
|
+
def number(count)
|
|
46
|
+
count = whole_number(count, "count")
|
|
47
|
+
"(?<![\\d/:.,\\-\u2013\u2014])#{MONTH_BEFORE}\\b#{count}\\b(?![/:\\-\u2013\u2014])(?![.,]\\d)(?! ?%)" \
|
|
48
|
+
"(?!(?i:(?:st|nd|rd|th)?,? (?:of )?(?:#{MONTH_AFTER})\\b))"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `count` of `total` as a percentage, whole or to one or two decimals,
|
|
52
|
+
# with an optional space before the sign: share(3, 4) matches "75%",
|
|
53
|
+
# "75 %", "75.0%" and "75.00%", not "175%", "7.5%", "75.5%" or "0.75".
|
|
54
|
+
# The whole percent rounds half up like Format.whole and the decimals
|
|
55
|
+
# like Float#round, so share(1, 8) matches "13%", "12.5%" and "12.50%",
|
|
56
|
+
# and share(12, 22) matches "55%", "54.5%" and "54.55%". Both arguments are read like
|
|
57
|
+
# `number`'s; a total below one and a count above the total raise
|
|
58
|
+
# ArgumentError.
|
|
59
|
+
def share(count, total)
|
|
60
|
+
count = whole_number(count, "count")
|
|
61
|
+
total = whole_number(total, "total", minimum: 1)
|
|
62
|
+
raise ArgumentError, "count must not exceed total" if count > total
|
|
63
|
+
|
|
64
|
+
percent = count * 100.0 / total
|
|
65
|
+
decimals = [ 2, 1 ].map { |places| Regexp.escape(format("%.#{places}f", percent.round(places))) }
|
|
66
|
+
"(?<![\\d.,])\\b(?:#{decimals.join('|')}|#{Format.whole(percent)}) ?%"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `value` as an Integer of at least `minimum`, read from its text so
|
|
70
|
+
# that 1.5 is rejected rather than truncated: 3 and "3" → 3; 1.5, "a"
|
|
71
|
+
# and a value below `minimum` raise ArgumentError.
|
|
72
|
+
def whole_number(value, name, minimum: 0)
|
|
73
|
+
number = Integer(value.to_s, 10)
|
|
74
|
+
raise ArgumentError, "#{name} must be #{minimum.positive? ? 'positive' : 'zero or positive'}" if number < minimum
|
|
75
|
+
|
|
76
|
+
number
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|