activeagent 1.3.0 → 1.4.0
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 +158 -0
- data/lib/active_agent/evals/design_tokens.rb +130 -0
- data/lib/active_agent/evals/diagnosis.rb +238 -0
- data/lib/active_agent/evals/judge.rb +205 -0
- data/lib/active_agent/evals/model_spec.rb +80 -0
- data/lib/active_agent/evals/replay.rb +63 -0
- data/lib/active_agent/evals/report.rb +447 -0
- data/lib/active_agent/evals/report_html.rb +634 -0
- data/lib/active_agent/evals/result.rb +78 -0
- data/lib/active_agent/evals/runner.rb +149 -0
- data/lib/active_agent/evals/scenario.rb +68 -0
- data/lib/active_agent/evals/scenario_parser.rb +215 -0
- data/lib/active_agent/evals/scorer.rb +118 -0
- data/lib/active_agent/evals/suite.rb +99 -0
- data/lib/active_agent/evals.rb +60 -0
- data/lib/active_agent/providers/ruby_llm/options.rb +4 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +14 -1
- data/lib/active_agent/providers/rubyllm_provider.rb +1 -0
- data/lib/active_agent/telemetry/configuration.rb +11 -0
- data/lib/active_agent/telemetry/instrumentation.rb +26 -6
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +1 -0
- metadata +22 -4
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# The outcome of one evaluation run: every scenario × model Result, a summary
|
|
6
|
+
# per model, criterion statistics, the faults grouped with the fix each
|
|
7
|
+
# calls for, and the model that did best. Renders as a hash, JSON,
|
|
8
|
+
# Markdown, or a self-contained HTML page (ReportHtml).
|
|
9
|
+
class Report
|
|
10
|
+
include ReportHtml
|
|
11
|
+
|
|
12
|
+
# The judge a verdict names when the framework ranked the models by
|
|
13
|
+
# pass rate itself, no judge having been available to rule on them.
|
|
14
|
+
PASS_RATE_JUDGE = "pass rate"
|
|
15
|
+
|
|
16
|
+
attr_reader :results, :models, :judge, :judge_label, :instructions, :metadata, :agent_name, :links, :tool_resolver
|
|
17
|
+
|
|
18
|
+
# @param tool_resolver [#call, nil] maps a tool name to the MCP server
|
|
19
|
+
# that provides it — `{ "key", "name", "status" }` with status
|
|
20
|
+
# "enabled", "available" or "unknown" — or nil; enriches +fix_items+
|
|
21
|
+
# @param agent_name [String, nil] how fix items name the agent
|
|
22
|
+
# @param links [Hash] route templates for fix item actions:
|
|
23
|
+
# `"mcp"` (`"/mcp/%{key}"`), `"tools"`, `"instructions"`. An action
|
|
24
|
+
# whose route is absent carries `"path" => nil`.
|
|
25
|
+
# @param verdict [Hash, nil] a verdict already recorded for these
|
|
26
|
+
# results — `{ "winner", "rationale", "judge" }`. A report rebuilt
|
|
27
|
+
# from a persisted run renders the pick that run recorded instead of
|
|
28
|
+
# ranking the results again (and re-asking the judge), so the page
|
|
29
|
+
# and the dashboard never name two different best models.
|
|
30
|
+
# @param judge_label [String, nil] how to name the judge when no Judge
|
|
31
|
+
# instance is at hand — a rebuilt run knows only its label.
|
|
32
|
+
def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
|
|
33
|
+
tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil)
|
|
34
|
+
@results = results
|
|
35
|
+
@models = models
|
|
36
|
+
@judge = judge
|
|
37
|
+
@judge_label = judge_label.presence
|
|
38
|
+
@instructions = instructions
|
|
39
|
+
@threshold = threshold
|
|
40
|
+
@metadata = metadata
|
|
41
|
+
@tool_resolver = tool_resolver
|
|
42
|
+
@agent_name = agent_name.presence || "the agent"
|
|
43
|
+
@links = (links || {}).to_h.stringify_keys
|
|
44
|
+
@recorded_verdict = verdict.is_a?(Hash) ? verdict.to_h.stringify_keys.presence : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def comparing?
|
|
48
|
+
@models.size > 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Per model, keyed by label: scenario count, passes, errors, pass rate,
|
|
52
|
+
# mean score, mean latency, tokens, cost and fault counts.
|
|
53
|
+
def summary_by_model
|
|
54
|
+
@summary_by_model ||= @models.to_h do |spec|
|
|
55
|
+
cohort = @results.select { |result| result.label == spec.label }
|
|
56
|
+
scored = cohort.filter_map(&:score)
|
|
57
|
+
durations = cohort.filter_map { |result| result.replay.duration_ms }
|
|
58
|
+
costs = cohort.filter_map { |result| result.replay.cost }
|
|
59
|
+
|
|
60
|
+
[ spec.label, {
|
|
61
|
+
"provider" => spec.provider,
|
|
62
|
+
"model" => spec.model,
|
|
63
|
+
"scenarios" => cohort.size,
|
|
64
|
+
"passed" => cohort.count(&:passed?),
|
|
65
|
+
"errored" => cohort.count(&:errored?),
|
|
66
|
+
"pass_rate" => cohort.any? ? (cohort.count(&:passed?) * 100.0 / cohort.size).round(1) : 0.0,
|
|
67
|
+
"avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
|
|
68
|
+
"avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
|
|
69
|
+
"input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
|
|
70
|
+
"output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
|
|
71
|
+
"cost" => costs.any? ? costs.sum.to_f.round(6) : nil,
|
|
72
|
+
"faults" => cohort.filter_map(&:fault).tally
|
|
73
|
+
} ]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Per criterion key: `{ "score", "min", "max", "passed", "total" }` over
|
|
78
|
+
# every result, or a map of model label => those stats when comparing
|
|
79
|
+
# models. A criterion nothing could score is `{ "skipped" => true }`.
|
|
80
|
+
def criterion_scores
|
|
81
|
+
@criterion_scores ||= criterion_keys.to_h do |key|
|
|
82
|
+
stats =
|
|
83
|
+
if comparing?
|
|
84
|
+
@models.to_h do |spec|
|
|
85
|
+
[ spec.label, stats_for(@results.select { |result| result.label == spec.label }.map { |result| result.scores[key] }) ]
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
stats_for(@results.map { |result| result.scores[key] })
|
|
89
|
+
end
|
|
90
|
+
[ key, stats ]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Faults across scenarios and models, most frequent first, each with the
|
|
95
|
+
# scenarios it hit and the fix it calls for.
|
|
96
|
+
def recommendations
|
|
97
|
+
@recommendations ||= @results.select(&:fault).group_by(&:fault).map do |fault, faulted|
|
|
98
|
+
{
|
|
99
|
+
"fault" => fault,
|
|
100
|
+
"count" => faulted.size,
|
|
101
|
+
"scenario_keys" => faulted.map { |result| result.scenario.key }.uniq,
|
|
102
|
+
"models" => faulted.map(&:label).uniq,
|
|
103
|
+
"recommendation" => faulted.filter_map(&:recommendation).tally.max_by(&:last)&.first,
|
|
104
|
+
"suggested_tools" => faulted.filter_map(&:suggested_tool).uniq
|
|
105
|
+
}
|
|
106
|
+
end.sort_by { |entry| -entry["count"] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# What to fix: one item per fault, in +recommendations+ order, plus one
|
|
110
|
+
# per distinct instruction change the judge proposed. Each item names
|
|
111
|
+
# the tools involved — the missing tools a scenario expected, the tools
|
|
112
|
+
# that errored, or the tools the judge suggested — the MCP server that
|
|
113
|
+
# provides them when +tool_resolver+ knows it, and the dashboard action
|
|
114
|
+
# that addresses it when +links+ carry the route:
|
|
115
|
+
#
|
|
116
|
+
# { "kind" => "fault" | "instruction", "fault" => "expected_tool_not_called", "count" => 3,
|
|
117
|
+
# "scenario_keys" => [...], "models" => [...], "recommendation" => "...", "quote" => nil,
|
|
118
|
+
# "tools_label" => "missing tools", "tools" => [ { "name", "note", "server" } ],
|
|
119
|
+
# "server" => { "key", "name", "status" } | nil, "note" => "..." | nil,
|
|
120
|
+
# "action" => { "label", "hint", "path" } | nil }
|
|
121
|
+
def fix_items
|
|
122
|
+
@fix_items ||= fault_fix_items + instruction_fix_items
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The best model when comparing: the verdict the run recorded when one
|
|
126
|
+
# was handed in, else highest pass rate, then mean score, then lowest
|
|
127
|
+
# cost (a model with no cost estimate ranks after one with), with the
|
|
128
|
+
# judge's rationale when one is available.
|
|
129
|
+
# `{ "winner", "rationale", "judge" }`, or nil for a single model.
|
|
130
|
+
def verdict
|
|
131
|
+
return @recorded_verdict if @recorded_verdict
|
|
132
|
+
return nil unless comparing?
|
|
133
|
+
|
|
134
|
+
@verdict ||= begin
|
|
135
|
+
ranked = summary_by_model.sort_by do |_label, stats|
|
|
136
|
+
[ -stats["pass_rate"].to_f, -stats["avg_score"].to_f, stats["cost"] || Float::INFINITY ]
|
|
137
|
+
end
|
|
138
|
+
winner, stats = ranked.first
|
|
139
|
+
rationale = "Passed #{stats['passed']} of #{stats['scenarios']} scenarios" \
|
|
140
|
+
"#{" with a mean score of #{stats['avg_score']}" if stats['avg_score']}" \
|
|
141
|
+
"#{" at an estimated $#{format('%.4f', stats['cost'])}" if stats['cost']}."
|
|
142
|
+
judged = @judge&.verdict(summary_by_model, instructions: @instructions)
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
"winner" => judged&.dig("winner").presence || winner,
|
|
146
|
+
"rationale" => judged&.dig("rationale").presence || rationale,
|
|
147
|
+
"judge" => judged ? @judge.label : PASS_RATE_JUDGE
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def winner
|
|
153
|
+
verdict&.dig("winner")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def to_h
|
|
157
|
+
{
|
|
158
|
+
"models" => summary_by_model,
|
|
159
|
+
"criteria" => criterion_scores,
|
|
160
|
+
"recommendations" => recommendations,
|
|
161
|
+
"verdict" => verdict,
|
|
162
|
+
"judge" => @judge&.label,
|
|
163
|
+
"metadata" => @metadata.presence,
|
|
164
|
+
"results" => @results.map(&:to_h)
|
|
165
|
+
}.compact
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_json(*args)
|
|
169
|
+
JSON.pretty_generate(to_h, *args)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def to_markdown
|
|
173
|
+
scenario_count = @results.map { |result| result.scenario.key }.uniq.size
|
|
174
|
+
lines = [ "# Evaluation — #{scenario_count} scenario#{'s' unless scenario_count == 1} × #{@models.size} model#{'s' unless @models.size == 1}", "" ]
|
|
175
|
+
lines << (@judge ? "Judged by `#{@judge.label}`." : "No judge; scored on rules and expectations alone.")
|
|
176
|
+
lines << ""
|
|
177
|
+
lines.concat(summary_table)
|
|
178
|
+
lines << ""
|
|
179
|
+
lines << "**Best model: #{winner}**" if winner
|
|
180
|
+
lines << ""
|
|
181
|
+
lines.concat(matrix_table)
|
|
182
|
+
lines.concat(recommendation_lines)
|
|
183
|
+
lines.concat(detail_lines)
|
|
184
|
+
lines.join("\n")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def criterion_keys
|
|
190
|
+
@results.flat_map { |result| result.scores.keys }.uniq
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def stats_for(values)
|
|
194
|
+
scored = values.compact
|
|
195
|
+
return { "skipped" => true, "reason" => "No scorable answers" } if scored.empty?
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
"score" => (scored.sum / scored.size).round(3),
|
|
199
|
+
"min" => scored.min.round(3),
|
|
200
|
+
"max" => scored.max.round(3),
|
|
201
|
+
"passed" => scored.count { |value| value >= @threshold },
|
|
202
|
+
"total" => scored.size
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# --- fix items ---------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def fault_fix_items
|
|
209
|
+
by_fault = @results.select(&:fault).group_by(&:fault)
|
|
210
|
+
recommendations.map do |entry|
|
|
211
|
+
faulted = by_fault[entry["fault"]]
|
|
212
|
+
tools_label, tools = fix_tools(entry["fault"], faulted)
|
|
213
|
+
server = tools_label == "missing tools" ? shared_server(tools) : nil
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
"kind" => "fault",
|
|
217
|
+
"fault" => entry["fault"],
|
|
218
|
+
"count" => entry["count"],
|
|
219
|
+
"scenario_keys" => entry["scenario_keys"],
|
|
220
|
+
"models" => entry["models"],
|
|
221
|
+
"recommendation" => fix_recommendation(entry, faulted, tools),
|
|
222
|
+
"quote" => nil,
|
|
223
|
+
"tools_label" => tools.any? ? tools_label : nil,
|
|
224
|
+
"tools" => tools,
|
|
225
|
+
"server" => server,
|
|
226
|
+
"note" => fix_note(entry["fault"], faulted, tools),
|
|
227
|
+
"action" => fix_action(tools_label, tools, server)
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def instruction_fix_items
|
|
233
|
+
@results.select { |result| result.diagnosis&.dig("judge", "instruction_change").present? }
|
|
234
|
+
.group_by { |result| result.diagnosis.dig("judge", "instruction_change").to_s.strip }
|
|
235
|
+
.map do |sentence, cohort|
|
|
236
|
+
{
|
|
237
|
+
"kind" => "instruction",
|
|
238
|
+
"fault" => "instruction change",
|
|
239
|
+
"count" => cohort.size,
|
|
240
|
+
"scenario_keys" => cohort.map { |result| result.scenario.key }.uniq,
|
|
241
|
+
"models" => cohort.map(&:label).uniq,
|
|
242
|
+
# The judge writes one recommendation per result and Runner#refine!
|
|
243
|
+
# puts it on the diagnosis, so it is already the text of the fault
|
|
244
|
+
# card built from the same result: the quote is what this card adds.
|
|
245
|
+
"recommendation" => nil,
|
|
246
|
+
"quote" => sentence,
|
|
247
|
+
"tools_label" => nil,
|
|
248
|
+
"tools" => [],
|
|
249
|
+
"server" => nil,
|
|
250
|
+
"note" => nil,
|
|
251
|
+
"action" => fix_action_for("Add to instructions", "Agent -> Instructions", link("instructions"))
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The fix the card asks for: the fault's most frequent recommendation,
|
|
257
|
+
# except on an `expected_tool_not_called` card that names missing tools.
|
|
258
|
+
# That card speaks for the scenarios whose tool was unavailable, so its
|
|
259
|
+
# text comes from those alone — the most frequent recommendation may be
|
|
260
|
+
# a scenario whose tool was there all along (a tie is won by whichever
|
|
261
|
+
# was seen first), whose wording contradicts the card's own tools,
|
|
262
|
+
# server and "Enable …" button. That exception keeps its say in +note+.
|
|
263
|
+
def fix_recommendation(entry, faulted, tools)
|
|
264
|
+
return entry["recommendation"] unless entry["fault"] == "expected_tool_not_called" && tools.any?
|
|
265
|
+
|
|
266
|
+
blocked = faulted.select { |result| unavailable_tools(result).any? }
|
|
267
|
+
blocked.filter_map(&:recommendation).tally.max_by(&:last)&.first || entry["recommendation"]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# [label, tools] for a fault: the tools the scenarios expected but the
|
|
271
|
+
# agent could not call, the tools that errored, or the tools the judge
|
|
272
|
+
# suggested — deduplicated by name.
|
|
273
|
+
def fix_tools(fault, faulted)
|
|
274
|
+
case fault
|
|
275
|
+
when "expected_tool_not_called"
|
|
276
|
+
[ "missing tools", faulted.flat_map { |result| unavailable_tools(result) }.uniq.map { |name| tool_entry(name) } ]
|
|
277
|
+
when "missing_capability"
|
|
278
|
+
names = suggested_tool_names(faulted) + faulted.flat_map { |result| unavailable_tools(result) }
|
|
279
|
+
[ "suggested tools", names.uniq.map { |name| tool_entry(name) } ]
|
|
280
|
+
when "tool_error"
|
|
281
|
+
failed = faulted.flat_map { |result| result.replay.failed_tool_calls }.uniq { |call| call["name"].to_s }
|
|
282
|
+
[ "failing tools", failed.map { |call| tool_entry(call["name"], note: call["detail"].to_s.truncate(60).presence) } ]
|
|
283
|
+
else
|
|
284
|
+
[ "suggested tools", suggested_tool_names(faulted).uniq.map { |name| tool_entry(name) } ]
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def suggested_tool_names(faulted)
|
|
289
|
+
faulted.filter_map { |result| result.suggested_tool&.dig("name").presence }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Tools the scenario expects that the agent could not call: what the
|
|
293
|
+
# diagnosis recorded as unavailable or, for a diagnosis without that
|
|
294
|
+
# evidence, the expected tools outside its toolset (or, failing that,
|
|
295
|
+
# the ones it did not call).
|
|
296
|
+
def unavailable_tools(result)
|
|
297
|
+
evidence = result.diagnosis&.dig("evidence") || {}
|
|
298
|
+
return Array(evidence["unavailable"]).map(&:to_s) if evidence.key?("unavailable")
|
|
299
|
+
return result.scenario.expected_tools - Array(evidence["tools_available"]).map(&:to_s) if evidence.key?("tools_available")
|
|
300
|
+
|
|
301
|
+
result.scenario.expected_tools - result.replay.tool_names
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def tool_entry(name, note: nil)
|
|
305
|
+
server = resolve_tool(name.to_s)
|
|
306
|
+
{ "name" => name.to_s, "note" => note || server&.dig("name"), "server" => server }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def resolve_tool(name)
|
|
310
|
+
return nil unless @tool_resolver
|
|
311
|
+
|
|
312
|
+
@resolved_tools ||= {}
|
|
313
|
+
return @resolved_tools[name] if @resolved_tools.key?(name)
|
|
314
|
+
|
|
315
|
+
resolved = @tool_resolver.call(name)
|
|
316
|
+
@resolved_tools[name] =
|
|
317
|
+
if resolved
|
|
318
|
+
server = resolved.to_h.stringify_keys
|
|
319
|
+
{ "key" => server["key"].to_s, "name" => server["name"].presence || server["key"].to_s,
|
|
320
|
+
"status" => server["status"].presence || "unknown" }
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The one server every tool resolves to, or nil when they differ or any
|
|
325
|
+
# is unknown.
|
|
326
|
+
def shared_server(tools)
|
|
327
|
+
servers = tools.map { |tool| tool["server"] }
|
|
328
|
+
return nil if servers.empty? || servers.any?(&:nil?)
|
|
329
|
+
|
|
330
|
+
servers.uniq { |server| server["key"] }.size == 1 ? servers.first : nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# For missing tools, the scenarios whose expected tool was available
|
|
334
|
+
# but went uncalled — the fix for those is instructions, not enabling
|
|
335
|
+
# a server.
|
|
336
|
+
def fix_note(fault, faulted, tools)
|
|
337
|
+
return nil unless fault == "expected_tool_not_called" && tools.any?
|
|
338
|
+
|
|
339
|
+
exceptions = faulted.select { |result| unavailable_tools(result).empty? }
|
|
340
|
+
return nil if exceptions.empty?
|
|
341
|
+
|
|
342
|
+
exceptions.map { |result| "#{result.scenario.key} is the exception: #{result.summary} #{result.recommendation}".strip }.uniq.join(" ")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def fix_action(tools_label, tools, server)
|
|
346
|
+
return nil if tools.empty?
|
|
347
|
+
|
|
348
|
+
case tools_label
|
|
349
|
+
when "missing tools"
|
|
350
|
+
if server && server["status"] != "enabled"
|
|
351
|
+
fix_action_for("Enable #{server['name']} for #{@agent_name}", "MCP Services ->", link("mcp", key: server["key"]))
|
|
352
|
+
else
|
|
353
|
+
fix_action_for("Open tools", "Tools ->", link("tools"))
|
|
354
|
+
end
|
|
355
|
+
when "failing tools"
|
|
356
|
+
fix_action_for("Open failing tools", "Tools ->", link("tools"))
|
|
357
|
+
else
|
|
358
|
+
fix_action_for("Open suggested tools", "Tools ->", link("tools"))
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def fix_action_for(label, hint, path)
|
|
363
|
+
{ "label" => label, "hint" => hint, "path" => path }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The route template for `name` with `%{key}`-style values filled in,
|
|
367
|
+
# or nil when the caller gave none.
|
|
368
|
+
def link(name, **values)
|
|
369
|
+
template = @links[name].to_s.presence
|
|
370
|
+
return template if template.nil? || values.empty?
|
|
371
|
+
|
|
372
|
+
format(template, **values)
|
|
373
|
+
rescue KeyError, ArgumentError
|
|
374
|
+
template
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# --- Markdown ----------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def summary_table
|
|
380
|
+
header = [ "| Model | Pass rate | Passed | Mean score | Mean latency | Tokens in/out | Cost | Faults |",
|
|
381
|
+
"|---|---|---|---|---|---|---|---|" ]
|
|
382
|
+
rows = summary_by_model.map do |label, stats|
|
|
383
|
+
faults = stats["faults"].map { |fault, count| "#{fault.tr('_', ' ')} ×#{count}" }.join(", ")
|
|
384
|
+
latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
|
|
385
|
+
cost = stats["cost"] ? format("$%.4f", stats["cost"]) : "—"
|
|
386
|
+
"| `#{label}` | #{stats['pass_rate']}% | #{stats['passed']}/#{stats['scenarios']} | #{stats['avg_score'] || '—'} | " \
|
|
387
|
+
"#{latency} | #{stats['input_tokens']}/#{stats['output_tokens']} | #{cost} | #{faults.presence || '—'} |"
|
|
388
|
+
end
|
|
389
|
+
header + rows
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def matrix_table
|
|
393
|
+
labels = @models.map(&:label)
|
|
394
|
+
header = [ "| Scenario | #{labels.map { |label| "`#{label}`" }.join(' | ')} |", "|---|#{labels.map { '---' }.join('|')}|" ]
|
|
395
|
+
rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
|
|
396
|
+
cells = labels.map do |label|
|
|
397
|
+
result = cohort.find { |candidate| candidate.label == label }
|
|
398
|
+
next "—" unless result
|
|
399
|
+
|
|
400
|
+
mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
|
|
401
|
+
[ mark, result.score&.round(2), result.fault&.tr("_", " ") ].compact.join(" ")
|
|
402
|
+
end
|
|
403
|
+
"| `#{key}` #{cell(cohort.first.scenario.prompt.truncate(70))} | #{cells.join(' | ')} |"
|
|
404
|
+
end
|
|
405
|
+
header + rows
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# A prompt may contain " | " (ScenarioParser keeps it), which would
|
|
409
|
+
# otherwise split the table cell.
|
|
410
|
+
def cell(text)
|
|
411
|
+
text.to_s.gsub("|") { "\\|" }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def recommendation_lines
|
|
415
|
+
return [] if recommendations.empty?
|
|
416
|
+
|
|
417
|
+
lines = [ "", "## Recommendations", "" ]
|
|
418
|
+
recommendations.each do |entry|
|
|
419
|
+
lines << "- **#{entry['fault'].tr('_', ' ')}** ×#{entry['count']} (#{entry['scenario_keys'].join(', ')}): #{entry['recommendation']}"
|
|
420
|
+
entry["suggested_tools"].each do |tool|
|
|
421
|
+
lines << " - suggested tool `#{tool['name']}`: #{tool['description']}"
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
lines
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def detail_lines
|
|
428
|
+
lines = [ "", "## Answers", "" ]
|
|
429
|
+
@results.each do |result|
|
|
430
|
+
lines << "### `#{result.scenario.key}` · `#{result.label}` · #{result.status}#{" · score #{result.score.round(2)}" if result.score}"
|
|
431
|
+
lines << ""
|
|
432
|
+
lines << "> #{result.scenario.prompt}"
|
|
433
|
+
lines << ""
|
|
434
|
+
if result.replay.tool_calls.any?
|
|
435
|
+
lines << "Tools: #{result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(', ')}"
|
|
436
|
+
end
|
|
437
|
+
lines << "Fault: #{result.fault.tr('_', ' ')} — #{result.summary} #{result.recommendation}" if result.fault
|
|
438
|
+
lines << "Error: #{result.replay.error}" if result.replay.error
|
|
439
|
+
lines << ""
|
|
440
|
+
lines << (result.replay.answer.presence || "_(no answer)_").to_s.truncate(1_500)
|
|
441
|
+
lines << ""
|
|
442
|
+
end
|
|
443
|
+
lines
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|