activeagent 1.9.6 → 1.9.7
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 +436 -28
- data/README.md +6 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +3 -3
- data/lib/active_agent/concerns/tooling.rb +9 -0
- data/lib/active_agent/evals/capture.rb +275 -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/providers/_base_provider.rb +17 -1
- 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/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/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/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +16 -3
- data/lib/tasks/activeagent_tasks.rake +0 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by an explicit `require "active_agent/evals/rspec"` — never by
|
|
4
|
+
# `require "active_agent/evals"` — so a host on another test framework never
|
|
5
|
+
# sees it. It needs nothing from rspec: the matcher speaks RSpec's matcher
|
|
6
|
+
# protocol on its own and loads with rspec absent.
|
|
7
|
+
require_relative "../evals"
|
|
8
|
+
|
|
9
|
+
module ActiveAgent
|
|
10
|
+
module Evals
|
|
11
|
+
# `expect(report).to meet_its_pass_gates`: an RSpec matcher over
|
|
12
|
+
# GateCheck, failing with its message, as TestHelper's
|
|
13
|
+
# `assert_meets_pass_gates` does. Include it beside TestHelper where the
|
|
14
|
+
# evals are set up, tagged and run only when EVALS is on:
|
|
15
|
+
#
|
|
16
|
+
# # spec/rails_helper.rb
|
|
17
|
+
# require "active_agent/evals/test_helper"
|
|
18
|
+
# require "active_agent/evals/rspec"
|
|
19
|
+
#
|
|
20
|
+
# RSpec.configure do |config|
|
|
21
|
+
# config.expect_with :rspec, :minitest
|
|
22
|
+
# config.define_derived_metadata(file_path: %r{/spec/evals/}) { |metadata| metadata[:eval] = true }
|
|
23
|
+
# config.include ActiveAgent::Evals::TestHelper, eval: true
|
|
24
|
+
# config.include ActiveAgent::Evals::RSpec, eval: true
|
|
25
|
+
# config.filter_run_excluding eval: true unless ActiveAgent::Evals::TestHelper.enabled?
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# # spec/evals/support_desk_spec.rb
|
|
29
|
+
# RSpec.describe "the support desk suite" do
|
|
30
|
+
# it "clears its pass gates" do
|
|
31
|
+
# report = run_eval_suite("config/evals/support_desk.yml") { |scenario, _spec| SupportAgent.with(question: scenario.prompt).help }
|
|
32
|
+
#
|
|
33
|
+
# expect(report).to meet_its_pass_gates
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
module RSpec
|
|
37
|
+
# The matcher: every gate of the report met under `thresholds`, or
|
|
38
|
+
# without them under the report's own bars, else the defaults.
|
|
39
|
+
def meet_its_pass_gates(thresholds = nil)
|
|
40
|
+
MeetItsPassGates.new(thresholds)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The matcher object, speaking RSpec's matcher protocol (`matches?`,
|
|
44
|
+
# the failure messages and `description`) without rspec loaded.
|
|
45
|
+
class MeetItsPassGates
|
|
46
|
+
def initialize(thresholds = nil)
|
|
47
|
+
@thresholds = thresholds
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Checks `report` and keeps the check for the failure messages.
|
|
51
|
+
def matches?(report)
|
|
52
|
+
@check = GateCheck.new(report, @thresholds)
|
|
53
|
+
@check.met?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# GateCheck#message: the unmet gates and the failures to read.
|
|
57
|
+
def failure_message
|
|
58
|
+
@check.message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# For `expect(report).not_to meet_its_pass_gates`: every gate, each
|
|
62
|
+
# met, as Gate#to_s.
|
|
63
|
+
def failure_message_when_negated
|
|
64
|
+
[ "expected the report not to meet its pass gates, but every gate was met:", *@check.gates.map(&:to_s) ].join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The generated example description: "meet its pass gates", or
|
|
68
|
+
# "meet the given pass gates" when thresholds were passed.
|
|
69
|
+
def description
|
|
70
|
+
@thresholds ? "meet the given pass gates" : "meet its pass gates"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# RSpec shows no diff: the failure message already lists the unmet
|
|
74
|
+
# gates.
|
|
75
|
+
def diffable?
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The matcher takes a report, not a block, so RSpec refuses
|
|
80
|
+
# `expect { ... }.to meet_its_pass_gates`.
|
|
81
|
+
def supports_block_expectations?
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -55,7 +55,13 @@ module ActiveAgent
|
|
|
55
55
|
# `chat_options` go to `chat` itself, so they must be keywords
|
|
56
56
|
# `RubyLLM::Chat.new` takes (`protocol:` on RubyLLM 2.x); anything else
|
|
57
57
|
# raises ArgumentError here rather than failing every judge call
|
|
58
|
-
# later.
|
|
58
|
+
# later. With `verify: true`, a missing key fails here too: the judge's
|
|
59
|
+
# label, model and provider go through `verify!` with `context` and
|
|
60
|
+
# `providers`, so a judge whose provider has no key, or is not one of
|
|
61
|
+
# the providers a host has keys for, refuses when it is built rather
|
|
62
|
+
# than at its first grade, after the agent has already been replayed;
|
|
63
|
+
# the default stays lazy. Configure the chat through `temperature:` or
|
|
64
|
+
# `configure:`:
|
|
59
65
|
#
|
|
60
66
|
# ActiveAgent::Evals::RubyLLM.judge(label: "judge", model: "gpt-5-mini", provider: :openai,
|
|
61
67
|
# temperature: 0,
|
|
@@ -74,12 +80,23 @@ module ActiveAgent
|
|
|
74
80
|
# `{ "kind", "model", "input_tokens", "output_tokens", "cost" }`, for a
|
|
75
81
|
# host that meters its judge's spend; an error it raises is logged,
|
|
76
82
|
# not propagated, so bookkeeping never costs a grade
|
|
83
|
+
# @param verify [Boolean] run `verify!` on the judge's own model with
|
|
84
|
+
# `context` when the judge is built, so a provider with no key
|
|
85
|
+
# refuses here rather than at the first grade
|
|
86
|
+
# @param providers [Array<String, Symbol>, nil] with `verify: true`, the
|
|
87
|
+
# providers a host has keys for, handed to `verify!` as its
|
|
88
|
+
# `providers:`; given without `verify: true` it raises ArgumentError
|
|
77
89
|
# @param chat_options [Hash] other `chat` keywords, such as `protocol:`
|
|
78
90
|
# @return [Judge]
|
|
79
|
-
# @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take
|
|
91
|
+
# @raise [ArgumentError] when `chat_options` names a keyword `RubyLLM::Chat.new` does not take,
|
|
92
|
+
# or `providers:` is given without `verify: true`
|
|
93
|
+
# @raise [::RubyLLM::ConfigurationError] with `verify: true`, when the judge's provider is not
|
|
94
|
+
# among `providers` or `context` cannot build the judge's chat
|
|
80
95
|
def judge(label:, model:, provider:, context: ::RubyLLM, correlation: nil, assume_model_exists: true,
|
|
81
|
-
temperature: nil, configure: nil, on_usage: nil, **chat_options)
|
|
96
|
+
temperature: nil, configure: nil, on_usage: nil, verify: false, providers: nil, **chat_options)
|
|
82
97
|
check_chat_options!(chat_options)
|
|
98
|
+
raise ArgumentError, "providers: is checked only with verify: true" if providers && !verify
|
|
99
|
+
verify!(ModelSpec.new(label: label, provider: provider, model: model), context: context, providers: providers) if verify
|
|
83
100
|
|
|
84
101
|
Judge.new(label: label) do |instructions:, prompt:, kind:|
|
|
85
102
|
complete = lambda do
|
|
@@ -96,6 +113,65 @@ module ActiveAgent
|
|
|
96
113
|
end
|
|
97
114
|
end
|
|
98
115
|
|
|
116
|
+
# Checks, before a run spends anything, that each model can be built
|
|
117
|
+
# on `context`: its provider is one RubyLLM registered, is one of
|
|
118
|
+
# `providers` when those are given, and is configured, which
|
|
119
|
+
# `context.chat(model:, provider:, assume_model_exists: true)` settles
|
|
120
|
+
# while it instantiates the provider. Nothing is sent over the
|
|
121
|
+
# network: RubyLLM resolves the model locally. The specs are checked
|
|
122
|
+
# in order, and the first that cannot run raises, naming what to
|
|
123
|
+
# change: the provider's configuration requirements that `context`
|
|
124
|
+
# leaves unset, as environment variable names, or RubyLLM's own
|
|
125
|
+
# message when those are all set and it refused for another reason,
|
|
126
|
+
# such as a credential it checks beyond them or a deployment it
|
|
127
|
+
# cannot resolve:
|
|
128
|
+
#
|
|
129
|
+
# context = RubyLLM.context { |config| config.openai_api_key = ENV["OPENAI_API_KEY"] }
|
|
130
|
+
# models = ActiveAgent::Evals::ModelSpec.parse_all(%w[gpt-5-mini claude-sonnet-5], default_provider: "openai")
|
|
131
|
+
#
|
|
132
|
+
# ActiveAgent::Evals::RubyLLM.verify!(models, context: context)
|
|
133
|
+
# # raises RubyLLM::ConfigurationError: claude-sonnet-5 runs on anthropic, which has no API key. Set ANTHROPIC_API_KEY.
|
|
134
|
+
#
|
|
135
|
+
# @param models [Array<ModelSpec>, ModelSpec] the run's models, or anything
|
|
136
|
+
# answering `label`, `provider` and `model`; a single spec is wrapped
|
|
137
|
+
# @param context [#chat] where `chat` is called; defaults to `::RubyLLM`.
|
|
138
|
+
# When it also answers `config`, as `::RubyLLM` and a `RubyLLM.context`
|
|
139
|
+
# do, a refusal names only the requirements that configuration leaves
|
|
140
|
+
# unset
|
|
141
|
+
# @param providers [Array<String, Symbol>, String, Symbol, nil] the
|
|
142
|
+
# providers the host has credentials for; a model on any other provider
|
|
143
|
+
# is refused before its chat is built
|
|
144
|
+
# @return [Array<ModelSpec>] the given models
|
|
145
|
+
# @raise [::RubyLLM::ConfigurationError] for the first model whose provider
|
|
146
|
+
# RubyLLM does not know, is outside `providers`, or cannot be built on
|
|
147
|
+
# `context`; in the last case RubyLLM's own error is the cause, and the
|
|
148
|
+
# message names what to set or carries RubyLLM's own
|
|
149
|
+
def verify!(models, context: ::RubyLLM, providers: nil)
|
|
150
|
+
specs = models.is_a?(Array) ? models : [ models ]
|
|
151
|
+
configured = Array(providers).map(&:to_s) if providers
|
|
152
|
+
|
|
153
|
+
specs.each do |spec|
|
|
154
|
+
provider = spec.provider.to_s
|
|
155
|
+
unless ::RubyLLM::Provider.providers.key?(provider.to_sym)
|
|
156
|
+
raise ::RubyLLM::ConfigurationError,
|
|
157
|
+
"#{spec.label} runs on #{provider}, which is not a RubyLLM provider; " \
|
|
158
|
+
"RubyLLM knows #{::RubyLLM::Provider.providers.keys.map(&:to_s).sort.join(', ')}"
|
|
159
|
+
end
|
|
160
|
+
if configured && !configured.include?(provider)
|
|
161
|
+
configured_for = configured.empty? ? "no provider has credentials configured" : "credentials are configured for #{configured.join(', ')}"
|
|
162
|
+
raise ::RubyLLM::ConfigurationError, "#{spec.label} runs on #{provider}; #{configured_for}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
context.chat(model: spec.model, provider: provider.to_sym, assume_model_exists: true)
|
|
167
|
+
rescue ::RubyLLM::ConfigurationError => e
|
|
168
|
+
raise ::RubyLLM::ConfigurationError, refusal(spec, provider, context, e)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
specs
|
|
173
|
+
end
|
|
174
|
+
|
|
99
175
|
# Builds a Replay from the ordered messages of a RubyLLM conversation:
|
|
100
176
|
# `acts_as_chat` message records of either RubyLLM generation (read
|
|
101
177
|
# through their own `to_llm`), `RubyLLM::Message` values, or anything
|
|
@@ -167,6 +243,32 @@ module ActiveAgent
|
|
|
167
243
|
"Pass temperature: to judge, or configure the chat with configure: ->(chat) { chat.with_... }."
|
|
168
244
|
end
|
|
169
245
|
|
|
246
|
+
# The message for a model whose provider refused to be built: the
|
|
247
|
+
# requirement names `context` leaves unset, as environment variable
|
|
248
|
+
# names, called an API key only when each of them is one; RubyLLM's
|
|
249
|
+
# own message when nothing is left to set.
|
|
250
|
+
def refusal(spec, provider, context, error)
|
|
251
|
+
names = unset_requirements(provider, context).map { |name| name.to_s.upcase }
|
|
252
|
+
return "#{spec.label} runs on #{provider}: #{error.message.strip}" if names.empty?
|
|
253
|
+
|
|
254
|
+
state = names.all? { |name| name.end_with?("_API_KEY") } ? "has no API key" : "is not configured"
|
|
255
|
+
"#{spec.label} runs on #{provider}, which #{state}. Set #{names.join(' and ')}."
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The provider's configuration requirements that `context` leaves
|
|
259
|
+
# unset: all of them when the context exposes no configuration, and
|
|
260
|
+
# none on a release whose providers do not list them.
|
|
261
|
+
def unset_requirements(provider, context)
|
|
262
|
+
provider_class = ::RubyLLM::Provider.providers[provider.to_sym]
|
|
263
|
+
return [] unless provider_class.respond_to?(:configuration_requirements)
|
|
264
|
+
|
|
265
|
+
requirements = provider_class.configuration_requirements
|
|
266
|
+
return requirements unless context.respond_to?(:config)
|
|
267
|
+
|
|
268
|
+
config = context.config
|
|
269
|
+
requirements.reject { |name| config.respond_to?(name) && config.public_send(name) }
|
|
270
|
+
end
|
|
271
|
+
|
|
170
272
|
def report_usage(callback, kind, model, response)
|
|
171
273
|
return unless callback
|
|
172
274
|
|
|
@@ -30,8 +30,9 @@ module ActiveAgent
|
|
|
30
30
|
# tool to add, or how to change the instructions.
|
|
31
31
|
DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called ungrounded_answer low_quality missing_content].freeze
|
|
32
32
|
DEFAULT_JUDGE_LIMIT = 25
|
|
33
|
+
DEFAULT_TRIALS = 1
|
|
33
34
|
|
|
34
|
-
attr_reader :scenarios, :models, :criteria, :judge, :threshold
|
|
35
|
+
attr_reader :scenarios, :models, :criteria, :judge, :threshold, :pass_thresholds, :judge_failed_expectations, :trials
|
|
35
36
|
|
|
36
37
|
# @param scenarios [Array<Scenario>]
|
|
37
38
|
# @param models [Array<ModelSpec>]
|
|
@@ -43,6 +44,12 @@ module ActiveAgent
|
|
|
43
44
|
# @param available_tools [Hash{String=>String}, Array<String>] the agent's tool roster
|
|
44
45
|
# @param instructions [String, nil] the agent's instructions, for the judge
|
|
45
46
|
# @param agent_name [String] how recommendations refer to the agent
|
|
47
|
+
# @param threshold [Float, nil] the score an answer must reach to pass;
|
|
48
|
+
# an explicit value wins over the `pass_thresholds` bar, and without
|
|
49
|
+
# either it is PASS_THRESHOLD
|
|
50
|
+
# @param pass_thresholds [PassThresholds, nil] the bars the suite
|
|
51
|
+
# declared (Suite#pass_thresholds): its threshold applies when none
|
|
52
|
+
# is given here, and the Report gates each group by it (Report#gates)
|
|
46
53
|
# @param on_result [#call, nil] called with each Result as it lands
|
|
47
54
|
# @param around_evaluation [#call, nil] called with (scenario, spec) and a
|
|
48
55
|
# block that returns the Result. Establishes context for replay, scoring
|
|
@@ -58,20 +65,38 @@ module ActiveAgent
|
|
|
58
65
|
# after `judge_limit`; false skips it. A caller that scores each
|
|
59
66
|
# scenario in its own Runner (a background job per scenario) shares one
|
|
60
67
|
# run-wide limit this way, since `judge_limit` counts per instance.
|
|
68
|
+
# @param judge_failed_expectations [Boolean] whether the judge grades a
|
|
69
|
+
# replay that missed one of its scenario's expectations (an expected
|
|
70
|
+
# tool not called, forbidden or missing content, a failed tool call).
|
|
71
|
+
# false skips its task grade, its llm_judge criteria and its judged
|
|
72
|
+
# recommendation, since it fails whatever the judge says, and the
|
|
73
|
+
# calls saved do not count against `judge_limit`. The default grades
|
|
74
|
+
# every answered replay.
|
|
75
|
+
# @param trials [Integer] how many times each scenario is replayed under
|
|
76
|
+
# each model, 1 or more. Every replay is a Result of its own under the
|
|
77
|
+
# scenario's key, told apart by Result#trial, so a flaky scenario
|
|
78
|
+
# reads as a pass share rather than one pass or fail.
|
|
61
79
|
def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
|
|
62
|
-
instructions: nil, agent_name: "The agent", threshold:
|
|
80
|
+
instructions: nil, agent_name: "The agent", threshold: nil, pass_thresholds: nil,
|
|
63
81
|
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
64
|
-
around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil
|
|
82
|
+
around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil,
|
|
83
|
+
judge_failed_expectations: true, trials: DEFAULT_TRIALS)
|
|
84
|
+
unless trials.is_a?(Integer) && trials >= 1
|
|
85
|
+
raise ArgumentError, "trials must be an Integer of 1 or more, got #{trials.inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
65
88
|
@scenarios = scenarios
|
|
66
89
|
@models = models
|
|
67
90
|
@replay = replay
|
|
91
|
+
@trials = trials
|
|
68
92
|
@criteria = criteria
|
|
69
93
|
@judge = judge
|
|
70
94
|
@judge_task = judge_task
|
|
71
95
|
@available_tools = normalize_tools(available_tools)
|
|
72
96
|
@instructions = instructions
|
|
73
97
|
@agent_name = agent_name
|
|
74
|
-
@threshold = threshold
|
|
98
|
+
@threshold = threshold || pass_thresholds&.threshold || PASS_THRESHOLD
|
|
99
|
+
@pass_thresholds = pass_thresholds
|
|
75
100
|
@refine_faults = refine_faults
|
|
76
101
|
@judge_limit = judge_limit
|
|
77
102
|
@on_result = on_result
|
|
@@ -80,27 +105,33 @@ module ActiveAgent
|
|
|
80
105
|
@metadata = metadata
|
|
81
106
|
@release = release
|
|
82
107
|
@refine_gate = refine_gate
|
|
108
|
+
@judge_failed_expectations = judge_failed_expectations
|
|
83
109
|
@judge_calls = 0
|
|
84
|
-
@scorer = Scorer.new(criteria: criteria, judge: judge)
|
|
110
|
+
@scorer = Scorer.new(criteria: criteria, judge: judge, judge_failed_expectations: judge_failed_expectations)
|
|
85
111
|
end
|
|
86
112
|
|
|
113
|
+
# Replays every scenario under every model `trials` times: scenario by
|
|
114
|
+
# scenario, model by model, trial 1 to `trials`.
|
|
87
115
|
def call
|
|
88
116
|
results = @scenarios.flat_map do |scenario|
|
|
89
|
-
@models.
|
|
90
|
-
|
|
117
|
+
@models.flat_map do |spec|
|
|
118
|
+
(1..@trials).map do |trial|
|
|
119
|
+
evaluate_with_context(scenario, spec, trial: trial).tap { |result| @on_result&.call(result) }
|
|
120
|
+
end
|
|
91
121
|
end
|
|
92
122
|
end
|
|
93
123
|
|
|
94
124
|
Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
|
|
95
|
-
threshold: @threshold, metadata: @metadata, release: @release)
|
|
125
|
+
threshold: @threshold, metadata: @metadata, release: @release, pass_thresholds: @pass_thresholds)
|
|
96
126
|
end
|
|
97
127
|
|
|
98
128
|
# Scores and diagnoses one replay. Public so a caller that has already run
|
|
99
129
|
# the agent (a background job per scenario, say) can score without the loop.
|
|
100
|
-
|
|
130
|
+
# `trial` numbers the Result when the caller replays a scenario more than once.
|
|
131
|
+
def evaluate(scenario, spec, replay = nil, trial: 1)
|
|
101
132
|
replay ||= run_replay(scenario, spec)
|
|
102
133
|
scores = @scorer.score(scenario, replay)
|
|
103
|
-
if judge_task? && replay.answer.present?
|
|
134
|
+
if judge_task? && replay.answer.present? && judge_answer?(scores)
|
|
104
135
|
scores["task_completion"] = @judge.score_task(scenario: scenario, answer: replay.answer)
|
|
105
136
|
end
|
|
106
137
|
score = Scorer.mean(scores)
|
|
@@ -110,7 +141,7 @@ module ActiveAgent
|
|
|
110
141
|
judge_keys: llm_judge_keys)
|
|
111
142
|
diagnosis ||= unavailable_judge_diagnosis(scores)
|
|
112
143
|
diagnosis_hash = diagnosis&.to_h
|
|
113
|
-
refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
|
|
144
|
+
refine!(diagnosis_hash, scenario, replay, diagnosis, scores) if diagnosis_hash
|
|
114
145
|
|
|
115
146
|
Result.new(
|
|
116
147
|
scenario: scenario,
|
|
@@ -119,16 +150,17 @@ module ActiveAgent
|
|
|
119
150
|
scores: scores,
|
|
120
151
|
score: score,
|
|
121
152
|
status: replay.errored? ? "errored" : (diagnosis ? "failed" : "passed"),
|
|
122
|
-
diagnosis: diagnosis_hash
|
|
153
|
+
diagnosis: diagnosis_hash,
|
|
154
|
+
trial: trial
|
|
123
155
|
)
|
|
124
156
|
end
|
|
125
157
|
|
|
126
158
|
private
|
|
127
159
|
|
|
128
|
-
def evaluate_with_context(scenario, spec)
|
|
129
|
-
return evaluate(scenario, spec) unless @around_evaluation
|
|
160
|
+
def evaluate_with_context(scenario, spec, trial:)
|
|
161
|
+
return evaluate(scenario, spec, trial: trial) unless @around_evaluation
|
|
130
162
|
|
|
131
|
-
result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
|
|
163
|
+
result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec, trial: trial) }
|
|
132
164
|
# A wrapper written the natural way — do something, yield, do something
|
|
133
165
|
# after — returns that last value rather than the Result. Left alone it
|
|
134
166
|
# reaches on_result and the Report, and fails somewhere far from the
|
|
@@ -144,6 +176,16 @@ module ActiveAgent
|
|
|
144
176
|
@judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
|
|
145
177
|
end
|
|
146
178
|
|
|
179
|
+
# Whether the judge is asked about a replay with these scores: always by
|
|
180
|
+
# default, or only when it met every expectation of its scenario. A
|
|
181
|
+
# replay that did not already carries one of the faults Diagnosis
|
|
182
|
+
# assigns ahead of low_quality (tool_error, missing_capability,
|
|
183
|
+
# expected_tool_not_called, ungrounded_answer, forbidden_content or
|
|
184
|
+
# missing_content), so its status is failed whatever the judge would say.
|
|
185
|
+
def judge_answer?(scores)
|
|
186
|
+
@judge_failed_expectations || Scorer.expectations_met?(scores)
|
|
187
|
+
end
|
|
188
|
+
|
|
147
189
|
# The keys in `scores` a judge graded, so a low grade is not averaged
|
|
148
190
|
# away against rule checks.
|
|
149
191
|
def llm_judge_keys
|
|
@@ -189,8 +231,9 @@ module ActiveAgent
|
|
|
189
231
|
end
|
|
190
232
|
end
|
|
191
233
|
|
|
192
|
-
def refine!(diagnosis, scenario, replay, result)
|
|
234
|
+
def refine!(diagnosis, scenario, replay, result, scores)
|
|
193
235
|
return unless @judge && @refine_faults.include?(result.fault)
|
|
236
|
+
return unless judge_answer?(scores)
|
|
194
237
|
return if @judge_calls >= @judge_limit
|
|
195
238
|
return if @refine_gate && !@refine_gate.call
|
|
196
239
|
|
|
@@ -24,16 +24,21 @@ module ActiveAgent
|
|
|
24
24
|
# @return [Array<String>] labels a catalog files the scenario under ("billing")
|
|
25
25
|
# @!attribute params
|
|
26
26
|
# @return [Hash] free-form parameters a replay may read; kept as written
|
|
27
|
+
# @!attribute metadata
|
|
28
|
+
# @return [Hash, nil] opaque extension fields from a catalog document
|
|
27
29
|
Scenario = Struct.new(:key, :group, :group_name, :prompt, :expected_tools, :expected_patterns,
|
|
28
|
-
:forbidden_patterns, :notes, :production_only, :position, :tags, :params,
|
|
30
|
+
:forbidden_patterns, :notes, :production_only, :position, :tags, :params, :metadata,
|
|
29
31
|
keyword_init: true) do
|
|
30
32
|
# Builds a scenario from a hash — ScenarioParser output, a suite entry, or
|
|
31
33
|
# a persisted record's attributes. Expectations are read from an
|
|
32
34
|
# `expectations`/`expect` sub-hash (`tools`, `contains`, `not_contains`)
|
|
33
35
|
# or from those keys at the top level.
|
|
34
|
-
|
|
36
|
+
# Pass +metadata+ when extension fields have been separated from a
|
|
37
|
+
# document; its literal `metadata` key may itself be an extension.
|
|
38
|
+
def self.from_hash(attributes, group: nil, group_name: nil, metadata: nil)
|
|
35
39
|
attrs = attributes.to_h.deep_stringify_keys
|
|
36
40
|
expect = (attrs["expectations"] || attrs["expect"] || {}).to_h.stringify_keys
|
|
41
|
+
metadata = attrs["metadata"] if metadata.nil? && attrs["metadata"].is_a?(Hash)
|
|
37
42
|
|
|
38
43
|
new(
|
|
39
44
|
key: attrs["key"].to_s,
|
|
@@ -47,7 +52,8 @@ module ActiveAgent
|
|
|
47
52
|
production_only: attrs["production_only"] == true,
|
|
48
53
|
position: attrs["position"],
|
|
49
54
|
tags: list(attrs["tags"]),
|
|
50
|
-
params: (attrs["params"] || {}).to_h.deep_stringify_keys
|
|
55
|
+
params: (attrs["params"] || {}).to_h.deep_stringify_keys,
|
|
56
|
+
metadata: (metadata || {}).to_h.deep_stringify_keys
|
|
51
57
|
)
|
|
52
58
|
end
|
|
53
59
|
|
|
@@ -68,8 +74,10 @@ module ActiveAgent
|
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
def to_h
|
|
71
|
-
super.compact.reject { |field, value| %i[tags params].include?(field) && value.blank? }
|
|
77
|
+
super.compact.reject { |field, value| %i[tags params metadata].include?(field) && value.blank? }
|
|
72
78
|
end
|
|
73
79
|
end
|
|
80
|
+
|
|
81
|
+
Scenario::DOCUMENT_KEYS = %w[key prompt expect expectations tools contains not_contains notes tags params production_only position].freeze
|
|
74
82
|
end
|
|
75
83
|
end
|
|
@@ -138,12 +138,20 @@ module ActiveAgent
|
|
|
138
138
|
end
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
suite_from(document).all_scenarios.map do |item|
|
|
142
142
|
scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
|
|
143
143
|
notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
|
|
144
144
|
end
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
+
# The document as a Suite; a `criteria` block the Suite refuses is a
|
|
148
|
+
# ParseError, as every other fault in a pasted document is.
|
|
149
|
+
def suite_from(document)
|
|
150
|
+
Suite.new([ document ])
|
|
151
|
+
rescue ArgumentError => e
|
|
152
|
+
raise ParseError, e.message
|
|
153
|
+
end
|
|
154
|
+
|
|
147
155
|
def scenario_from_hash(entry)
|
|
148
156
|
entry = entry.stringify_keys
|
|
149
157
|
prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
|
|
@@ -4,7 +4,8 @@ module ActiveAgent
|
|
|
4
4
|
module Evals
|
|
5
5
|
# Scores one Replay against an evaluation's criteria and the scenario's
|
|
6
6
|
# expectations. Returns `criterion key => 0.0..1.0`, with nil for a
|
|
7
|
-
# criterion that could not be scored (an llm_judge criterion with no judge
|
|
7
|
+
# criterion that could not be scored (an llm_judge criterion with no judge,
|
|
8
|
+
# or one the Scorer did not ask because the expectations failed).
|
|
8
9
|
#
|
|
9
10
|
# Criteria are `{ "key", "type", "config" }` hashes:
|
|
10
11
|
#
|
|
@@ -22,41 +23,37 @@ module ActiveAgent
|
|
|
22
23
|
class Scorer
|
|
23
24
|
RULE_CRITERION_TYPES = %w[response_present min_length max_latency_ms token_budget contains not_contains].freeze
|
|
24
25
|
CRITERION_TYPES = (RULE_CRITERION_TYPES + %w[llm_judge]).freeze
|
|
26
|
+
# The keys `score` adds from the scenario's own expectations.
|
|
27
|
+
EXPECTATION_KEYS = %w[expected_tools expected_content forbidden_content tools_succeeded].freeze
|
|
25
28
|
|
|
26
|
-
attr_reader :criteria, :judge
|
|
29
|
+
attr_reader :criteria, :judge, :judge_failed_expectations
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
# @param criteria [Array<Hash>] the criteria every replay is scored on
|
|
32
|
+
# @param judge [Judge, nil] scores the llm_judge criteria
|
|
33
|
+
# @param judge_failed_expectations [Boolean] whether an llm_judge criterion
|
|
34
|
+
# is asked of a replay that missed one of its scenario's expectations;
|
|
35
|
+
# false leaves it nil, since the replay fails on the expectation anyway
|
|
36
|
+
def initialize(criteria: [], judge: nil, judge_failed_expectations: true)
|
|
29
37
|
@criteria = Array(criteria).map { |criterion| criterion.to_h.deep_stringify_keys }
|
|
30
38
|
@judge = judge
|
|
39
|
+
@judge_failed_expectations = judge_failed_expectations
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
def score(scenario, replay)
|
|
34
43
|
scores = {}
|
|
35
44
|
answer = replay.answer.to_s
|
|
45
|
+
expectations = expectation_scores(scenario, replay)
|
|
46
|
+
ask_judge = answer.present? && (@judge_failed_expectations || self.class.expectations_met?(expectations))
|
|
36
47
|
|
|
37
48
|
@criteria.each do |criterion|
|
|
38
|
-
scores[criterion["key"]] =
|
|
49
|
+
scores[criterion["key"]] =
|
|
50
|
+
if answer.blank? then 0.0
|
|
51
|
+
elsif criterion["type"] == "llm_judge" && !ask_judge then nil
|
|
52
|
+
else score_criterion(criterion, scenario, replay)
|
|
53
|
+
end
|
|
39
54
|
end
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
|
|
43
|
-
end
|
|
44
|
-
if scenario.expected_patterns.any?
|
|
45
|
-
hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
|
|
46
|
-
scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
|
|
47
|
-
end
|
|
48
|
-
if scenario.forbidden_patterns.any?
|
|
49
|
-
hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
|
|
50
|
-
scores["forbidden_content"] = hit ? 0.0 : 1.0
|
|
51
|
-
end
|
|
52
|
-
# A tool that ran without erroring is evidence only when it is one the
|
|
53
|
-
# scenario expected: a wrong tool that succeeded used to outscore
|
|
54
|
-
# calling nothing at all.
|
|
55
|
-
if replay.tool_calls.any? && (scenario.expected_tools.empty? || (scenario.expected_tools & replay.tool_names).any?)
|
|
56
|
-
scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
scores
|
|
56
|
+
scores.merge(expectations)
|
|
60
57
|
end
|
|
61
58
|
|
|
62
59
|
# The mean of the scored criteria, or nil when nothing could be scored.
|
|
@@ -67,6 +64,12 @@ module ActiveAgent
|
|
|
67
64
|
(scored.sum / scored.size).round(3)
|
|
68
65
|
end
|
|
69
66
|
|
|
67
|
+
# Whether every expectation score in `scores` is a full 1.0. A scores
|
|
68
|
+
# hash with no expectation keys is met.
|
|
69
|
+
def self.expectations_met?(scores)
|
|
70
|
+
scores.slice(*EXPECTATION_KEYS).values.all? { |value| value.to_f >= 1.0 }
|
|
71
|
+
end
|
|
72
|
+
|
|
70
73
|
# How long one pattern may take to match one answer. Patterns are
|
|
71
74
|
# whatever the scenario's author typed, and a pathological one must not
|
|
72
75
|
# stall the evaluation.
|
|
@@ -90,6 +93,33 @@ module ActiveAgent
|
|
|
90
93
|
|
|
91
94
|
private
|
|
92
95
|
|
|
96
|
+
# The scores the scenario's own expectations add, in the order they appear
|
|
97
|
+
# after the criteria.
|
|
98
|
+
def expectation_scores(scenario, replay)
|
|
99
|
+
scores = {}
|
|
100
|
+
answer = replay.answer.to_s
|
|
101
|
+
|
|
102
|
+
if scenario.expected_tools.any?
|
|
103
|
+
scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
|
|
104
|
+
end
|
|
105
|
+
if scenario.expected_patterns.any?
|
|
106
|
+
hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
|
|
107
|
+
scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
|
|
108
|
+
end
|
|
109
|
+
if scenario.forbidden_patterns.any?
|
|
110
|
+
hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
|
|
111
|
+
scores["forbidden_content"] = hit ? 0.0 : 1.0
|
|
112
|
+
end
|
|
113
|
+
# A tool that ran without erroring is evidence only when it is one the
|
|
114
|
+
# scenario expected: a wrong tool that succeeded used to outscore
|
|
115
|
+
# calling nothing at all.
|
|
116
|
+
if replay.tool_calls.any? && (scenario.expected_tools.empty? || (scenario.expected_tools & replay.tool_names).any?)
|
|
117
|
+
scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
scores
|
|
121
|
+
end
|
|
122
|
+
|
|
93
123
|
def score_criterion(criterion, scenario, replay)
|
|
94
124
|
config = criterion["config"] || {}
|
|
95
125
|
answer = replay.answer.to_s
|