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
|
@@ -20,12 +20,21 @@ module ActiveAgent
|
|
|
20
20
|
# ArgumentError before anything is sent.
|
|
21
21
|
class Publisher
|
|
22
22
|
DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
|
|
23
|
+
# The hosted dashboard people sign in to; the hosted collector answers
|
|
24
|
+
# on the API host, where a dashboard session does not reach.
|
|
25
|
+
HOSTED_DASHBOARD_URL = "https://activeagents.ai"
|
|
23
26
|
MAX_BYTES = 2 * 1024 * 1024
|
|
24
27
|
DETAIL_LIMIT = 200
|
|
25
28
|
|
|
26
29
|
# @return [String] the collector URL reports go to
|
|
27
30
|
attr_reader :endpoint
|
|
28
31
|
|
|
32
|
+
# @return [String] the address a receipt's "url" path resolves against: the
|
|
33
|
+
# dashboard_url given, or by default the endpoint's origin (its scheme,
|
|
34
|
+
# host and any non-default port, with no path), except that the hosted
|
|
35
|
+
# API host resolves to HOSTED_DASHBOARD_URL
|
|
36
|
+
attr_reader :dashboard_url
|
|
37
|
+
|
|
29
38
|
# Raised for every failed delivery. Only a network failure keeps the
|
|
30
39
|
# underlying error as its +cause+, so neither the response nor the
|
|
31
40
|
# report reaches a log through the exception chain.
|
|
@@ -68,7 +77,14 @@ module ActiveAgent
|
|
|
68
77
|
# The key is sent as a bearer token and filtered from the collector's
|
|
69
78
|
# explanation, so it is limited to visible ASCII: the sanitizer in
|
|
70
79
|
# +collector_detail+ never alters it, and an echo of it always matches.
|
|
71
|
-
|
|
80
|
+
#
|
|
81
|
+
# @param dashboard_url [String, nil] the address people open the dashboard at,
|
|
82
|
+
# where a receipt's "url" path resolves; by default the endpoint's origin,
|
|
83
|
+
# or HOSTED_DASHBOARD_URL for the hosted collector.
|
|
84
|
+
# The receipt's path, which carries the engine's mount, replaces any path on it
|
|
85
|
+
# @raise [ArgumentError] "Evaluation dashboard_url is not an http(s) URL" when it is anything else, or
|
|
86
|
+
# "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment" when it carries one of those
|
|
87
|
+
def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, dashboard_url: nil, timeout: 10, open_timeout: 10)
|
|
72
88
|
@uri = URI.parse(endpoint.to_s)
|
|
73
89
|
unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
|
|
74
90
|
raise ArgumentError, "Evaluation endpoint must be an HTTP(S) URL without credentials, query or fragment"
|
|
@@ -78,6 +94,8 @@ module ActiveAgent
|
|
|
78
94
|
end
|
|
79
95
|
@endpoint = @uri.to_s
|
|
80
96
|
|
|
97
|
+
@dashboard_url = dashboard_url.nil? ? default_dashboard_url(@uri) : checked_dashboard_url(dashboard_url)
|
|
98
|
+
|
|
81
99
|
@api_key = api_key.to_s.strip
|
|
82
100
|
raise ArgumentError, "Evaluation API key is required" if @api_key.empty?
|
|
83
101
|
unless @api_key.match?(/\A[\x21-\x7E]+\z/)
|
|
@@ -93,9 +111,42 @@ module ActiveAgent
|
|
|
93
111
|
raise ArgumentError, "Evaluation endpoint is not a valid URL"
|
|
94
112
|
end
|
|
95
113
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
114
|
+
# Returns a publisher built from +env+: ACTIVEAGENTS_API_KEY is the key,
|
|
115
|
+
# ACTIVEAGENTS_EVALUATIONS_ENDPOINT the collector (DEFAULT_ENDPOINT when
|
|
116
|
+
# blank) and ACTIVEAGENTS_DASHBOARD_URL the dashboard a receipt's "url"
|
|
117
|
+
# path resolves against (the endpoint's origin when blank), so
|
|
118
|
+
# publishing is one line:
|
|
119
|
+
#
|
|
120
|
+
# Publisher.from_env.call(report: report, source: "support-app", agent_name: "SupportBot", suite: "support_desk")
|
|
121
|
+
#
|
|
122
|
+
# @param env [#[]] the environment to read, ENV by default
|
|
123
|
+
# @raise [ArgumentError] "ACTIVEAGENTS_API_KEY is not set" for a blank key, and what +new+ raises for the other two
|
|
124
|
+
# @return [Publisher]
|
|
125
|
+
def self.from_env(env = ENV)
|
|
126
|
+
api_key = env["ACTIVEAGENTS_API_KEY"].to_s.strip
|
|
127
|
+
raise ArgumentError, "ACTIVEAGENTS_API_KEY is not set" if api_key.empty?
|
|
128
|
+
|
|
129
|
+
endpoint = env["ACTIVEAGENTS_EVALUATIONS_ENDPOINT"].to_s.strip
|
|
130
|
+
dashboard_url = env["ACTIVEAGENTS_DASHBOARD_URL"].to_s.strip
|
|
131
|
+
new(api_key: api_key, endpoint: endpoint.empty? ? DEFAULT_ENDPOINT : endpoint, dashboard_url: dashboard_url.empty? ? nil : dashboard_url)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns the collector's receipt for the completed +report+, a Report
|
|
135
|
+
# or its saved JSON hash. Full prompts, answers and tool results are
|
|
136
|
+
# included; applications should make publication opt-in.
|
|
137
|
+
#
|
|
138
|
+
# The receipt's "url" is the run's dashboard page, made absolute: a path
|
|
139
|
+
# beginning with "/" is joined onto +dashboard_url+. An absolute URL, a
|
|
140
|
+
# url that is neither (a relative reference without a leading slash, a
|
|
141
|
+
# network-path reference, or a path that is not a valid URI) and a
|
|
142
|
+
# receipt without one are left as the collector sent them.
|
|
143
|
+
#
|
|
144
|
+
# @param run_id [String, nil] the run's identity, kept across retries; by default
|
|
145
|
+
# the one the report's metadata records under "run_id" or :run_id, which
|
|
146
|
+
# Correlation#with_run puts there
|
|
147
|
+
# @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
|
|
148
|
+
def call(report:, run_id: nil, source:, agent_name:, suite:)
|
|
149
|
+
run_id = resolve_run_id(report, run_id)
|
|
99
150
|
envelope = self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite)
|
|
100
151
|
publish(envelope, accepted: %w[complete], receipt: "completion receipt")
|
|
101
152
|
end
|
|
@@ -104,13 +155,18 @@ module ActiveAgent
|
|
|
104
155
|
# which the collector shows as a running run under +run_id+. Each call
|
|
105
156
|
# replaces the results the previous one sent; +call+ with the finished
|
|
106
157
|
# report under the same run_id completes the run. The receipt's status
|
|
107
|
-
# is "running", or "complete" when the run already finished
|
|
158
|
+
# is "running", or "complete" when the run already finished, and its
|
|
159
|
+
# "url" is made absolute as +call+ makes it.
|
|
108
160
|
#
|
|
109
161
|
# Raises Error, without sending the report, when the collector does not
|
|
110
162
|
# list "progress" among its capabilities (see +supports_progress?+): a
|
|
111
163
|
# collector that predates progress would store the partial report as
|
|
112
164
|
# complete.
|
|
113
|
-
|
|
165
|
+
#
|
|
166
|
+
# @param run_id [String, nil] as for +call+: by default the one the report's metadata records
|
|
167
|
+
# @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
|
|
168
|
+
def progress(report:, run_id: nil, source:, agent_name:, suite:)
|
|
169
|
+
run_id = resolve_run_id(report, run_id)
|
|
114
170
|
unless supports_progress?
|
|
115
171
|
raise Error, "Evaluation collector does not accept progress; publish the finished report with call"
|
|
116
172
|
end
|
|
@@ -186,8 +242,54 @@ module ActiveAgent
|
|
|
186
242
|
|
|
187
243
|
private
|
|
188
244
|
|
|
245
|
+
# The run_id a delivery goes under: +run_id+ when given, otherwise the
|
|
246
|
+
# one the report's metadata records under a String or Symbol key, which
|
|
247
|
+
# Correlation#with_run puts there. Raises ArgumentError for a report
|
|
248
|
+
# that is neither a Report nor a hash, as +envelope+ does, and when
|
|
249
|
+
# neither +run_id+ nor the metadata names a nonempty string.
|
|
250
|
+
def resolve_run_id(report, run_id)
|
|
251
|
+
return run_id unless run_id.nil?
|
|
252
|
+
|
|
253
|
+
hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
|
|
254
|
+
raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
|
|
255
|
+
|
|
256
|
+
metadata = hash["metadata"]
|
|
257
|
+
recorded = metadata["run_id"] || metadata[:run_id] if metadata.is_a?(Hash)
|
|
258
|
+
return recorded if recorded.is_a?(String) && !recorded.strip.empty?
|
|
259
|
+
|
|
260
|
+
raise ArgumentError, "run_id must be given, or the report's metadata must carry one (Correlation#with_run records it)"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The hosted collector's API host serves no dashboard session, so its
|
|
264
|
+
# receipts resolve against the hosted dashboard; any other collector is
|
|
265
|
+
# its own dashboard.
|
|
266
|
+
def default_dashboard_url(uri)
|
|
267
|
+
uri.hostname == URI.parse(DEFAULT_ENDPOINT).hostname ? HOSTED_DASHBOARD_URL : origin(uri)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Returns the dashboard_url given as a String, raising ArgumentError
|
|
271
|
+
# unless it is an http(s) URL with a host and without credentials, a
|
|
272
|
+
# query or a fragment.
|
|
273
|
+
def checked_dashboard_url(value)
|
|
274
|
+
uri = URI.parse(value.to_s)
|
|
275
|
+
raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
276
|
+
if uri.userinfo || uri.query || uri.fragment
|
|
277
|
+
raise ArgumentError, "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
value.to_s
|
|
281
|
+
rescue URI::InvalidURIError
|
|
282
|
+
raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The origin of +uri+: its scheme, host and any non-default port, with no path.
|
|
286
|
+
def origin(uri)
|
|
287
|
+
uri.dup.tap { |without_path| without_path.path = "" }.to_s
|
|
288
|
+
end
|
|
289
|
+
|
|
189
290
|
# Delivers +envelope+ and returns the collector's receipt, which must name
|
|
190
|
-
# the envelope's run_id, a stored run, and one of the +accepted+ statuses
|
|
291
|
+
# the envelope's run_id, a stored run, and one of the +accepted+ statuses,
|
|
292
|
+
# with a "url" path made absolute on the dashboard.
|
|
191
293
|
def publish(envelope, accepted:, receipt:)
|
|
192
294
|
body = encode(envelope)
|
|
193
295
|
raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
|
|
@@ -201,10 +303,24 @@ module ActiveAgent
|
|
|
201
303
|
answer["id"] && answer["evaluation_id"]
|
|
202
304
|
raise Error.new("Evaluation collector returned an invalid #{receipt}; retain the report and run_id for retry", retryable: true)
|
|
203
305
|
end
|
|
204
|
-
answer
|
|
306
|
+
resolve_url(answer)
|
|
205
307
|
end
|
|
206
308
|
end
|
|
207
309
|
|
|
310
|
+
# Returns +receipt+ with a "url" that is a path beginning with "/" joined
|
|
311
|
+
# onto +dashboard_url+. Any other url (an absolute one, a relative
|
|
312
|
+
# reference without a leading slash, a network-path reference, a path
|
|
313
|
+
# that is not a valid URI) and a receipt without one are left as the
|
|
314
|
+
# collector sent them.
|
|
315
|
+
def resolve_url(receipt)
|
|
316
|
+
url = receipt["url"]
|
|
317
|
+
return receipt unless url.is_a?(String) && url.start_with?("/") && !url.start_with?("//")
|
|
318
|
+
|
|
319
|
+
receipt.merge("url" => URI.join(@dashboard_url, url).to_s)
|
|
320
|
+
rescue URI::InvalidURIError
|
|
321
|
+
receipt
|
|
322
|
+
end
|
|
323
|
+
|
|
208
324
|
# The string entries of a JSON object body's +capabilities+ array, or none.
|
|
209
325
|
def collector_capabilities(body)
|
|
210
326
|
parsed = JSON.parse(body.to_s)
|
|
@@ -14,7 +14,33 @@ module ActiveAgent
|
|
|
14
14
|
# pass rate itself, no judge having been available to rule on them.
|
|
15
15
|
PASS_RATE_JUDGE = "pass rate"
|
|
16
16
|
|
|
17
|
-
attr_reader :results, :models, :judge, :judge_label, :instructions, :metadata, :agent_name, :links, :tool_resolver
|
|
17
|
+
attr_reader :results, :models, :judge, :judge_label, :instructions, :metadata, :agent_name, :links, :tool_resolver,
|
|
18
|
+
:pass_thresholds
|
|
19
|
+
|
|
20
|
+
# One group's (or the whole report's, with group nil and name "All")
|
|
21
|
+
# pass rate under one model against the bar the suite set for it.
|
|
22
|
+
# `pass_rate` and `min_pass_rate` are 0..1 fractions, so they compare
|
|
23
|
+
# directly and render with Format.percent; an ungated row is always met.
|
|
24
|
+
Gate = Struct.new(:group, :name, :label, :passed, :total, :pass_rate, :min_pass_rate, :gated, keyword_init: true) do
|
|
25
|
+
def met?
|
|
26
|
+
!gated || pass_rate >= min_pass_rate
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
super.transform_keys(&:to_s).merge("met" => met?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# "Order support · gpt-5-mini: 1/2 (50%) passed, below 100%" — one
|
|
34
|
+
# sentence a failing assertion can print.
|
|
35
|
+
def to_s
|
|
36
|
+
bar =
|
|
37
|
+
if !gated then "not gated"
|
|
38
|
+
elsif met? then "at or above #{Format.percent(min_pass_rate)}"
|
|
39
|
+
else "below #{Format.percent(min_pass_rate)}"
|
|
40
|
+
end
|
|
41
|
+
"#{name} · #{label}: #{Format.passes(passed, total, style: :markdown)} passed, #{bar}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
18
44
|
|
|
19
45
|
# @param tool_resolver [#call, nil] maps a tool name to the MCP server
|
|
20
46
|
# that provides it — `{ "key", "name", "status" }` with status
|
|
@@ -41,15 +67,22 @@ module ActiveAgent
|
|
|
41
67
|
# @param comparison [Hash, nil] a comparison already recorded for these
|
|
42
68
|
# results (Comparison#to_h). A report rebuilt from a persisted run
|
|
43
69
|
# renders the intervals that run recorded instead of resampling them.
|
|
44
|
-
|
|
70
|
+
# @param threshold [Float, nil] the score an answer must reach to pass
|
|
71
|
+
# a criterion; an explicit value wins over the `pass_thresholds` bar,
|
|
72
|
+
# and without either it is PASS_THRESHOLD
|
|
73
|
+
# @param pass_thresholds [PassThresholds, nil] the bars the suite
|
|
74
|
+
# declared: its threshold applies when none is given here, #gates
|
|
75
|
+
# and #passed? read them, and `to_h` carries them with the per-group
|
|
76
|
+
# summary and the gates.
|
|
77
|
+
def initialize(results:, models:, judge: nil, instructions: nil, threshold: nil, metadata: {},
|
|
45
78
|
tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil,
|
|
46
|
-
judge_usage: nil, release: nil, comparison: nil)
|
|
79
|
+
judge_usage: nil, release: nil, comparison: nil, pass_thresholds: nil)
|
|
47
80
|
@results = results
|
|
48
81
|
@models = models
|
|
49
82
|
@judge = judge
|
|
50
83
|
@judge_label = judge_label.presence
|
|
51
84
|
@instructions = instructions
|
|
52
|
-
@threshold = threshold
|
|
85
|
+
@threshold = threshold || pass_thresholds&.threshold || PASS_THRESHOLD
|
|
53
86
|
@metadata = metadata
|
|
54
87
|
@tool_resolver = tool_resolver
|
|
55
88
|
@agent_name = agent_name.presence || "the agent"
|
|
@@ -58,6 +91,7 @@ module ActiveAgent
|
|
|
58
91
|
@run_judge_usage = judge_usage.is_a?(Hash) ? judge_usage.to_h.stringify_keys.presence : nil
|
|
59
92
|
@release = release.is_a?(Hash) ? release.to_h.stringify_keys.compact.presence : nil
|
|
60
93
|
@recorded_comparison = comparison.is_a?(Hash) ? comparison.to_h.deep_stringify_keys.presence : nil
|
|
94
|
+
@pass_thresholds = pass_thresholds
|
|
61
95
|
end
|
|
62
96
|
|
|
63
97
|
# The release of the agent this report scored, `{ "digest",
|
|
@@ -68,12 +102,20 @@ module ActiveAgent
|
|
|
68
102
|
@models.size > 1
|
|
69
103
|
end
|
|
70
104
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
# How many times each scenario was replayed under each model: the
|
|
106
|
+
# highest trial number any result carries, 1 for a run with none.
|
|
107
|
+
def trials
|
|
108
|
+
@results.map(&:trial).max || 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Per model, keyed by label: replay count ("scenarios", every trial of
|
|
112
|
+
# every scenario), distinct scenarios ("unique_scenarios"), passes,
|
|
113
|
+
# errors, pass rate over every replay, mean score, latency (mean, and
|
|
114
|
+
# the P50 and P95 by nearest rank, Statistics.percentile), tokens,
|
|
115
|
+
# cost and fault counts. "cost" sums the replays that carried a cost
|
|
116
|
+
# — "priced" of the "scenarios", of which "reported" came from the
|
|
117
|
+
# caller and "estimated" from tokens × a model rate
|
|
118
|
+
# (Result#cost_source) — and is nil when none did.
|
|
77
119
|
# "judge_cost" and "judge_calls" sum what the judge spent on the
|
|
78
120
|
# cohort's results (Result#judge_usage); the judge's cost never joins
|
|
79
121
|
# the agent's.
|
|
@@ -90,6 +132,7 @@ module ActiveAgent
|
|
|
90
132
|
"provider" => spec.provider,
|
|
91
133
|
"model" => spec.model,
|
|
92
134
|
"scenarios" => cohort.size,
|
|
135
|
+
"unique_scenarios" => cohort.map { |result| result.scenario.key }.uniq.size,
|
|
93
136
|
"passed" => cohort.count(&:passed?),
|
|
94
137
|
"errored" => cohort.count(&:errored?),
|
|
95
138
|
"pass_rate" => cohort.any? ? (cohort.count(&:passed?) * 100.0 / cohort.size).round(1) : 0.0,
|
|
@@ -111,6 +154,58 @@ module ActiveAgent
|
|
|
111
154
|
end
|
|
112
155
|
end
|
|
113
156
|
|
|
157
|
+
# Per group key, in run order, then per model under "models" (keyed by
|
|
158
|
+
# label, only the models with a result in the group): scenario count,
|
|
159
|
+
# passes, errors, pass rate as a percent, mean score and fault counts,
|
|
160
|
+
# counted as summary_by_model counts them. "name" is the group's
|
|
161
|
+
# display name, or its key. Scenarios with no group are left out.
|
|
162
|
+
def summary_by_group
|
|
163
|
+
@summary_by_group ||= begin
|
|
164
|
+
grouped = @results.select { |result| result.scenario.group.present? }.group_by { |result| result.scenario.group }
|
|
165
|
+
grouped.to_h do |group, cohort|
|
|
166
|
+
models = @models.filter_map do |spec|
|
|
167
|
+
rows = cohort.select { |result| result.label == spec.label }
|
|
168
|
+
[ spec.label, cohort_summary(rows) ] if rows.any?
|
|
169
|
+
end
|
|
170
|
+
[ group, { "name" => group_name(cohort.first.scenario), "models" => models.to_h } ]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# One Gate per group and model with results there, in summary_by_group
|
|
176
|
+
# order, then one per model for the whole report (group nil, name
|
|
177
|
+
# "All") over every gated result of that model, ungrouped results
|
|
178
|
+
# included; a model with no gated result has no "All" row. Each row
|
|
179
|
+
# carries the bar `thresholds` sets for its group.
|
|
180
|
+
def gates(thresholds = pass_thresholds || PassThresholds.default)
|
|
181
|
+
rows = summary_by_group.flat_map do |group, summary|
|
|
182
|
+
summary["models"].map do |label, stats|
|
|
183
|
+
gate(group, summary["name"], label, stats["passed"], stats["scenarios"],
|
|
184
|
+
thresholds.min_pass_rate(group), thresholds.gated?(group))
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
gated = @results.select { |result| thresholds.gated?(result.scenario.group) }
|
|
188
|
+
rows + @models.filter_map do |spec|
|
|
189
|
+
cohort = gated.select { |result| result.label == spec.label }
|
|
190
|
+
gate(nil, "All", spec.label, cohort.count(&:passed?), cohort.size, thresholds.min_pass_rate(nil), true) if cohort.any?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Whether every gate is met. A report with no gate, because no result
|
|
195
|
+
# reached it (a selection that matched nothing, every scenario left
|
|
196
|
+
# out as production_only), has shown nothing and does not pass.
|
|
197
|
+
def passed?(thresholds = pass_thresholds || PassThresholds.default)
|
|
198
|
+
rows = gates(thresholds)
|
|
199
|
+
rows.any? && rows.all?(&:met?)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The results that did not pass — failed and errored — in run order,
|
|
203
|
+
# narrowed to one group when given.
|
|
204
|
+
def failures(group: nil)
|
|
205
|
+
failed = @results.reject(&:passed?)
|
|
206
|
+
group.nil? ? failed : failed.select { |result| result.scenario.group.to_s == group.to_s }
|
|
207
|
+
end
|
|
208
|
+
|
|
114
209
|
# Per scenario key, in run order: what every model's answer cost
|
|
115
210
|
# together — the agent's cost summed over the models, the judge's, and
|
|
116
211
|
# the two as "total" — plus the same per model under "models", keyed
|
|
@@ -258,7 +353,7 @@ module ActiveAgent
|
|
|
258
353
|
cost = " at #{Format.money(stats['cost'], estimated: estimated_cost?(stats))}"
|
|
259
354
|
cost += " (estimated)" if estimated_cost?(stats)
|
|
260
355
|
end
|
|
261
|
-
rationale = "Passed #{stats['passed']} of #{stats
|
|
356
|
+
rationale = "Passed #{stats['passed']} of #{counted_scenarios(stats)}" \
|
|
262
357
|
" (#{Format.percent(stats['scenarios'].to_i.positive? ? stats['passed'].to_f / stats['scenarios'] : nil)})" \
|
|
263
358
|
"#{" with a mean score of #{Format.score(stats['avg_score'])}" if stats['avg_score']}#{cost}."
|
|
264
359
|
judged = @judge&.verdict(summary_by_model, instructions: @instructions)
|
|
@@ -286,21 +381,28 @@ module ActiveAgent
|
|
|
286
381
|
@comparison ||= @recorded_comparison || Comparison.new(results: @results, models: @models).to_h
|
|
287
382
|
end
|
|
288
383
|
|
|
289
|
-
# Adds "judge_usage" and "release" only when there is one to add,
|
|
290
|
-
# "comparison" only when comparing models,
|
|
291
|
-
#
|
|
384
|
+
# Adds "judge_usage" and "release" only when there is one to add,
|
|
385
|
+
# "comparison" only when comparing models, "trials" only when a scenario
|
|
386
|
+
# was replayed more than once, and "pass_thresholds" with the per-group
|
|
387
|
+
# summary ("groups") and the "gates" only when the suite declared its
|
|
388
|
+
# bars, so a single-model, single-trial report built the way it always
|
|
389
|
+
# was serializes exactly as before.
|
|
292
390
|
def to_h
|
|
293
391
|
{
|
|
294
392
|
"models" => summary_by_model,
|
|
393
|
+
"trials" => (trials if trials > 1),
|
|
295
394
|
"criteria" => criterion_scores,
|
|
296
395
|
"recommendations" => recommendations,
|
|
297
396
|
"verdict" => verdict,
|
|
298
397
|
"comparison" => comparison,
|
|
398
|
+
"pass_thresholds" => pass_thresholds&.to_h&.merge("threshold" => @threshold),
|
|
399
|
+
"groups" => (summary_by_group if pass_thresholds),
|
|
400
|
+
"gates" => (gates.map(&:to_h) if pass_thresholds),
|
|
299
401
|
"judge" => @judge_label || @judge&.label,
|
|
300
402
|
"judge_usage" => judge_usage,
|
|
301
403
|
"release" => release,
|
|
302
404
|
"metadata" => @metadata.presence,
|
|
303
|
-
"results" =>
|
|
405
|
+
"results" => serialized_results
|
|
304
406
|
}.compact
|
|
305
407
|
end
|
|
306
408
|
|
|
@@ -310,6 +412,14 @@ module ActiveAgent
|
|
|
310
412
|
|
|
311
413
|
private
|
|
312
414
|
|
|
415
|
+
# Each result's hash; a single-trial report leaves out every "trial",
|
|
416
|
+
# so it is the JSON an earlier gem wrote and a re-delivery of it under
|
|
417
|
+
# the same run_id matches the stored digest.
|
|
418
|
+
def serialized_results
|
|
419
|
+
hashes = @results.map(&:to_h)
|
|
420
|
+
trials > 1 ? hashes : hashes.map { |hash| hash.except("trial") }
|
|
421
|
+
end
|
|
422
|
+
|
|
313
423
|
# Whether a model summary's cost is an estimate — any replay priced
|
|
314
424
|
# from tokens, or some replay unpriced, which leaves the sum a lower
|
|
315
425
|
# bound — and so reads with a "~".
|
|
@@ -339,10 +449,37 @@ module ActiveAgent
|
|
|
339
449
|
stats["cost"].to_f / priced if stats["cost"] && priced.positive?
|
|
340
450
|
end
|
|
341
451
|
|
|
452
|
+
# The denominator a verdict names: "6 scenarios", or "18 replays of 6
|
|
453
|
+
# scenarios" when the model replayed each more than once.
|
|
454
|
+
def counted_scenarios(stats)
|
|
455
|
+
replays = stats["scenarios"].to_i
|
|
456
|
+
unique = stats["unique_scenarios"].to_i
|
|
457
|
+
replays > unique ? "#{replays} replays of #{unique} scenarios" : "#{replays} scenarios"
|
|
458
|
+
end
|
|
459
|
+
|
|
342
460
|
def criterion_keys
|
|
343
461
|
@results.flat_map { |result| result.scores.keys }.uniq
|
|
344
462
|
end
|
|
345
463
|
|
|
464
|
+
# One model's counts over the results of a group, the way
|
|
465
|
+
# summary_by_model counts a model's whole cohort.
|
|
466
|
+
def cohort_summary(cohort)
|
|
467
|
+
scored = cohort.filter_map(&:score)
|
|
468
|
+
{
|
|
469
|
+
"scenarios" => cohort.size,
|
|
470
|
+
"passed" => cohort.count(&:passed?),
|
|
471
|
+
"errored" => cohort.count(&:errored?),
|
|
472
|
+
"pass_rate" => cohort.any? ? (cohort.count(&:passed?) * 100.0 / cohort.size).round(1) : 0.0,
|
|
473
|
+
"avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
|
|
474
|
+
"faults" => cohort.filter_map(&:fault).tally
|
|
475
|
+
}
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def gate(group, name, label, passed, total, min_pass_rate, gated)
|
|
479
|
+
Gate.new(group: group, name: name, label: label, passed: passed, total: total,
|
|
480
|
+
pass_rate: passed.to_f / total, min_pass_rate: min_pass_rate, gated: gated)
|
|
481
|
+
end
|
|
482
|
+
|
|
346
483
|
def stats_for(values)
|
|
347
484
|
scored = values.compact
|
|
348
485
|
return { "skipped" => true, "reason" => "No scorable answers" } if scored.empty?
|
|
@@ -628,17 +765,25 @@ module ActiveAgent
|
|
|
628
765
|
header = [ "| Scenario | #{labels.map { |label| cell("`#{label}`") }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
|
|
629
766
|
rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
|
|
630
767
|
cells = labels.map do |label|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
|
|
635
|
-
cell([ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" "))
|
|
768
|
+
mine = cohort.select { |candidate| candidate.label == label }.sort_by(&:trial)
|
|
769
|
+
mine.empty? ? "—" : cell(matrix_cell_text(mine))
|
|
636
770
|
end
|
|
637
771
|
"| #{cell("`#{key}` #{cohort.first.scenario.prompt.truncate(70)}")} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
|
|
638
772
|
end
|
|
639
773
|
header + rows
|
|
640
774
|
end
|
|
641
775
|
|
|
776
|
+
# "✅ 100%" or "❌ 50% missing capability" for one result; for a
|
|
777
|
+
# scenario replayed more than once, every trial's mark run together
|
|
778
|
+
# ("✅✅❌"), the mean of the trials' scores, and the fault seen most
|
|
779
|
+
# often among them (a tie goes to the first seen).
|
|
780
|
+
def matrix_cell_text(results)
|
|
781
|
+
marks = results.map { |result| status_mark(result) }.join
|
|
782
|
+
scored = results.filter_map(&:score)
|
|
783
|
+
fault = results.filter_map(&:fault).tally.max_by(&:last)&.first
|
|
784
|
+
[ marks, (Format.score(scored.sum.fdiv(scored.size)) if scored.any?), fault&.tr("_", " ") ].compact.join(" ")
|
|
785
|
+
end
|
|
786
|
+
|
|
642
787
|
# "~$0.0243 (judge ~$0.0015)" for a scenario's total across models.
|
|
643
788
|
def scenario_cost_cell(key)
|
|
644
789
|
costs = scenario_costs[key] || {}
|