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
|
@@ -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
|
|
@@ -6,13 +6,30 @@ module ActiveAgent
|
|
|
6
6
|
# layered over earlier ones: a scenario with a key already present replaces
|
|
7
7
|
# it, a new key is appended to its group, and a new group is appended to the
|
|
8
8
|
# suite. That is how an app keeps a shared suite and lets a deployment add or
|
|
9
|
-
# reword questions.
|
|
9
|
+
# reword questions. A `pass` block, on the suite and on a group, declares
|
|
10
|
+
# the bars a run is held to (PassThresholds); later documents override it
|
|
11
|
+
# key by key at both levels. Named criteria lists layer the same way by
|
|
12
|
+
# criterion key, and `criteria("shared", "terse")` hands the chosen lists
|
|
13
|
+
# to Runner.new(criteria:).
|
|
10
14
|
#
|
|
11
15
|
# suite: support_desk
|
|
12
16
|
# description: Questions the support team asks every week
|
|
17
|
+
# pass:
|
|
18
|
+
# threshold: 0.7
|
|
19
|
+
# min_pass_rate: 0.8
|
|
20
|
+
# criteria:
|
|
21
|
+
# shared:
|
|
22
|
+
# - key: cites_order
|
|
23
|
+
# type: contains
|
|
24
|
+
# config: { pattern: "ABC-" }
|
|
25
|
+
# terse:
|
|
26
|
+
# - key: short
|
|
27
|
+
# type: min_length
|
|
28
|
+
# config: { chars: 20 }
|
|
13
29
|
# groups:
|
|
14
30
|
# - key: open_tickets
|
|
15
31
|
# name: Open tickets
|
|
32
|
+
# pass: { min_pass_rate: 1.0 }
|
|
16
33
|
# scenarios:
|
|
17
34
|
# - key: open_tickets_1
|
|
18
35
|
# prompt: Which open tickets mention a refund?
|
|
@@ -23,7 +40,7 @@ module ActiveAgent
|
|
|
23
40
|
class Suite
|
|
24
41
|
class NotFound < StandardError; end
|
|
25
42
|
|
|
26
|
-
attr_reader :name, :description, :groups
|
|
43
|
+
attr_reader :name, :description, :groups, :pass_thresholds
|
|
27
44
|
|
|
28
45
|
# Loads the documents at `paths` (missing files are skipped) and raises
|
|
29
46
|
# NotFound when none exist.
|
|
@@ -35,20 +52,75 @@ module ActiveAgent
|
|
|
35
52
|
new(documents, name: name || File.basename(existing.first, ".yml"))
|
|
36
53
|
end
|
|
37
54
|
|
|
55
|
+
# The scenarios among `scenarios` whose key is in `keys`, in the order of
|
|
56
|
+
# `scenarios`. `keys` is an Array of Strings or Symbols or one
|
|
57
|
+
# comma-separated String; blanks are dropped, a repeated key selects
|
|
58
|
+
# once, and blank `keys` leave `scenarios` unchanged. Every key must name
|
|
59
|
+
# a scenario in `known`, which may be wider than `scenarios` (several
|
|
60
|
+
# suites' scenarios together), else NotFound lists the missing keys in
|
|
61
|
+
# the order given, "in <name>" appended when `name` is given. The result
|
|
62
|
+
# is empty when `known` holds the keys but `scenarios` does not.
|
|
63
|
+
def self.select(scenarios, keys:, known: scenarios, name: nil)
|
|
64
|
+
keys = keys.to_s.split(",") unless keys.is_a?(Array)
|
|
65
|
+
keys = keys.map { |key| key.to_s.strip }.reject(&:empty?).uniq
|
|
66
|
+
return scenarios if keys.empty?
|
|
67
|
+
|
|
68
|
+
missing = keys - known.map(&:key)
|
|
69
|
+
if missing.any?
|
|
70
|
+
where = name.present? ? " in #{name}" : ""
|
|
71
|
+
raise NotFound, "no scenario #{missing.join(', ')}#{where}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
scenarios.select { |scenario| keys.include?(scenario.key) }
|
|
75
|
+
end
|
|
76
|
+
|
|
38
77
|
# @param documents [Array<Hash>] parsed YAML documents, base first
|
|
39
78
|
def initialize(documents, name: nil)
|
|
40
79
|
documents = Array(documents).map(&:deep_stringify_keys)
|
|
41
80
|
@name = documents.filter_map { |doc| doc["suite"] }.last || name
|
|
42
81
|
@description = documents.filter_map { |doc| doc["description"] }.last
|
|
82
|
+
@pass_thresholds = PassThresholds.from_documents(documents)
|
|
43
83
|
@groups = merge_groups(documents)
|
|
84
|
+
@criteria_lists = merge_criteria_lists(documents)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The names of the suite's criteria lists, in document order.
|
|
88
|
+
def criteria_names
|
|
89
|
+
@criteria_lists.keys
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The criteria of the named lists in the order given, a name given twice
|
|
93
|
+
# contributing once, or of every list when no names are given. A
|
|
94
|
+
# criterion whose key an earlier list already holds replaces it in
|
|
95
|
+
# place, so an audience's list can restate a shared criterion with its
|
|
96
|
+
# own bar and the answer is scored on it once. A name no document
|
|
97
|
+
# declared contributes nothing, so a caller can ask for "shared" plus an
|
|
98
|
+
# audience that has no list of its own, and a nil or blank name is
|
|
99
|
+
# skipped, so an audience left unset narrows nothing. The result is
|
|
100
|
+
# ready for Runner.new(criteria:).
|
|
101
|
+
def criteria(*names)
|
|
102
|
+
names = names.flatten.filter_map { |list_name| list_name.to_s.presence }.uniq
|
|
103
|
+
names = criteria_names if names.empty?
|
|
104
|
+
names.each_with_object([]) do |list_name, chosen|
|
|
105
|
+
@criteria_lists.fetch(list_name, []).each do |criterion|
|
|
106
|
+
index = chosen.index { |existing| existing["key"] == criterion["key"] }
|
|
107
|
+
index ? chosen[index] = criterion : chosen << criterion
|
|
108
|
+
end
|
|
109
|
+
end
|
|
44
110
|
end
|
|
45
111
|
|
|
46
112
|
# Scenarios narrowed by group keys, scenario keys, or both; `production_only`
|
|
47
|
-
# scenarios are dropped unless `include_production_only` is true.
|
|
48
|
-
|
|
113
|
+
# scenarios are dropped unless `include_production_only` is true. With
|
|
114
|
+
# `strict`, every key in `keys` must name a scenario of the suite (see
|
|
115
|
+
# Suite.select), and a key outside the chosen groups selects nothing.
|
|
116
|
+
def scenarios(groups: nil, keys: nil, include_production_only: true, strict: false)
|
|
49
117
|
selected = all_scenarios
|
|
50
118
|
selected = selected.select { |scenario| Array(groups).map(&:to_s).include?(scenario.group) } if groups.present?
|
|
51
|
-
|
|
119
|
+
if strict
|
|
120
|
+
selected = Suite.select(selected, keys: keys, known: all_scenarios, name: name)
|
|
121
|
+
elsif keys.present?
|
|
122
|
+
selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) }
|
|
123
|
+
end
|
|
52
124
|
selected = selected.reject(&:production_only?) unless include_production_only
|
|
53
125
|
selected
|
|
54
126
|
end
|
|
@@ -56,7 +128,8 @@ module ActiveAgent
|
|
|
56
128
|
def all_scenarios
|
|
57
129
|
@all_scenarios ||= @groups.flat_map do |group|
|
|
58
130
|
group["scenarios"].each_with_index.map do |entry, index|
|
|
59
|
-
Scenario.from_hash(entry.merge("position" => index), group: group["key"], group_name: group["name"]
|
|
131
|
+
Scenario.from_hash(entry.merge("position" => index), group: group["key"], group_name: group["name"],
|
|
132
|
+
metadata: entry.except(*Scenario::DOCUMENT_KEYS, "group", "group_name"))
|
|
60
133
|
end
|
|
61
134
|
end
|
|
62
135
|
end
|
|
@@ -80,6 +153,7 @@ module ActiveAgent
|
|
|
80
153
|
if existing
|
|
81
154
|
existing["name"] = incoming["name"] if incoming["name"].present?
|
|
82
155
|
existing["description"] = incoming["description"] if incoming["description"].present?
|
|
156
|
+
existing["pass"] = (existing["pass"] || {}).merge(incoming["pass"]) if incoming["pass"].present?
|
|
83
157
|
merge_scenarios(existing, Array(incoming["scenarios"]))
|
|
84
158
|
else
|
|
85
159
|
groups << incoming.merge("scenarios" => Array(incoming["scenarios"]))
|
|
@@ -94,6 +168,43 @@ module ActiveAgent
|
|
|
94
168
|
index ? group["scenarios"][index] = scenario : group["scenarios"] << scenario
|
|
95
169
|
end
|
|
96
170
|
end
|
|
171
|
+
|
|
172
|
+
# Each document's criteria lists layered over the earlier ones by name
|
|
173
|
+
# and, within a name, by criterion key.
|
|
174
|
+
def merge_criteria_lists(documents)
|
|
175
|
+
documents.each_with_object({}) do |document, lists|
|
|
176
|
+
criteria_lists(document).each do |list_name, incoming|
|
|
177
|
+
list = (lists[list_name] ||= [])
|
|
178
|
+
incoming.each do |criterion|
|
|
179
|
+
index = list.index { |existing| existing["key"] == criterion["key"] }
|
|
180
|
+
index ? list[index] = criterion : list << criterion
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A document's `criteria` as `name => list`; a bare list is the one list
|
|
187
|
+
# named "default", and an empty one declares no list.
|
|
188
|
+
def criteria_lists(document)
|
|
189
|
+
value = document["criteria"]
|
|
190
|
+
return {} if value.nil? || value == []
|
|
191
|
+
|
|
192
|
+
value = { "default" => value } if value.is_a?(Array)
|
|
193
|
+
raise ArgumentError, "the criteria of suite #{name.inspect} are not a list or a mapping of lists" unless value.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
value.to_h do |list_name, list|
|
|
196
|
+
raise ArgumentError, "criteria list #{list_name.inspect} of suite #{name.inspect} is not a list" unless list.is_a?(Array)
|
|
197
|
+
|
|
198
|
+
[ list_name.to_s, list.map { |criterion| normalize_criterion(criterion, list_name) } ]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_criterion(criterion, list_name)
|
|
203
|
+
raise ArgumentError, "a criterion in list #{list_name.inspect} of suite #{name.inspect} is not a mapping" unless criterion.is_a?(Hash)
|
|
204
|
+
raise ArgumentError, "a criterion in list #{list_name.inspect} of suite #{name.inspect} has no key" if criterion["key"].blank?
|
|
205
|
+
|
|
206
|
+
criterion.merge("key" => criterion["key"].to_s)
|
|
207
|
+
end
|
|
97
208
|
end
|
|
98
209
|
end
|
|
99
210
|
end
|