llm-evals 0.4.0 → 0.5.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 +38 -0
- data/README.md +163 -87
- data/exe/llm-evals +60 -5
- data/lib/.DS_Store +0 -0
- data/lib/llm/.DS_Store +0 -0
- data/lib/llm/evals/actions/adapter.rb +50 -0
- data/lib/llm/evals/actions/error_budget.rb +63 -0
- data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
- data/lib/llm/evals/actions/error_streak.rb +83 -0
- data/lib/llm/evals/actions/experiment.rb +251 -0
- data/lib/llm/evals/actions/verify.rb +61 -0
- data/lib/llm/evals/actions.rb +9 -0
- data/lib/llm/evals/cli/cases.rb +10 -0
- data/lib/llm/evals/cli/command.rb +32 -99
- data/lib/llm/evals/cli/compare.rb +0 -20
- data/lib/llm/evals/cli/contender_selector.rb +61 -0
- data/lib/llm/evals/cli/error_pause.rb +81 -0
- data/lib/llm/evals/cli/experiment_pool.rb +97 -0
- data/lib/llm/evals/cli/formatting.rb +8 -0
- data/lib/llm/evals/cli/leaderboard.rb +66 -12
- data/lib/llm/evals/cli/model_colors.rb +3 -0
- data/lib/llm/evals/cli/model_command.rb +152 -315
- data/lib/llm/evals/cli/model_options.rb +80 -0
- data/lib/llm/evals/cli/option_groups.rb +32 -0
- data/lib/llm/evals/cli/progress_bars.rb +92 -5
- data/lib/llm/evals/cli/render.rb +6 -6
- data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
- data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
- data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
- data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
- data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
- data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
- data/lib/llm/evals/cli/run.rb +13 -22
- data/lib/llm/evals/cli/run_planner.rb +97 -0
- data/lib/llm/evals/cli/run_record.rb +68 -0
- data/lib/llm/evals/cli/standings.rb +39 -17
- data/lib/llm/evals/cli/status_box.rb +51 -0
- data/lib/llm/evals/cli/table.rb +103 -0
- data/lib/llm/evals/cli/text.rb +22 -0
- data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
- data/lib/llm/evals/cli/upgrade_config.rb +60 -0
- data/lib/llm/evals/cli/verify.rb +5 -1
- data/lib/llm/evals/cli/version.rb +4 -0
- data/lib/llm/evals/cli/weights.rb +48 -0
- data/lib/llm/evals/cli.rb +39 -6
- data/lib/llm/evals/config/contender_builder.rb +30 -0
- data/lib/llm/evals/config/error_limits.rb +41 -0
- data/lib/llm/evals/config/listing.rb +153 -0
- data/lib/llm/evals/config/results.rb +42 -0
- data/lib/llm/evals/config/score_weights.rb +25 -0
- data/lib/llm/evals/config/upgrade.rb +107 -0
- data/lib/llm/evals/config.rb +90 -79
- data/lib/llm/evals/datasets/case.rb +46 -0
- data/lib/llm/evals/datasets/discovery.rb +143 -0
- data/lib/llm/evals/datasets.rb +9 -0
- data/lib/llm/evals/launcher.rb +57 -3
- data/lib/llm/evals/models/contender.rb +51 -0
- data/lib/llm/evals/models/model.rb +87 -0
- data/lib/llm/evals/models/throttle.rb +170 -0
- data/lib/llm/evals/models.rb +9 -0
- data/lib/llm/evals/providers/base.rb +61 -16
- data/lib/llm/evals/providers/catalog.rb +5 -2
- data/lib/llm/evals/providers/open_router.rb +54 -7
- data/lib/llm/evals/providers/registry.rb +49 -16
- data/lib/llm/evals/providers/type_safe/client.rb +67 -0
- data/lib/llm/evals/providers/type_safe.rb +113 -12
- data/lib/llm/evals/providers.rb +9 -0
- data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
- data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
- data/lib/llm/evals/render/reports/html/format.rb +38 -0
- data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
- data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
- data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
- data/lib/llm/evals/render/reports/html.rb +60 -181
- data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
- data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
- data/lib/llm/evals/render/reports/json.rb +38 -141
- data/lib/llm/evals/render/reports/pipeline.rb +55 -20
- data/lib/llm/evals/render/reports/terminal.rb +8 -1
- data/lib/llm/evals/render.rb +9 -0
- data/lib/llm/evals/results/provider_response.rb +25 -0
- data/lib/llm/evals/results/reply.rb +38 -0
- data/lib/llm/evals/results/run_outcome.rb +15 -0
- data/lib/llm/evals/results/run_result.rb +40 -0
- data/lib/llm/evals/results/score.rb +45 -0
- data/lib/llm/evals/results/warnings.rb +149 -0
- data/lib/llm/evals/results.rb +9 -0
- data/lib/llm/evals/runtime/configuration.rb +295 -0
- data/lib/llm/evals/runtime/environment.rb +90 -0
- data/lib/llm/evals/runtime/helper.rb +153 -0
- data/lib/llm/evals/runtime.rb +2 -67
- data/lib/llm/evals/scoring/metric.rb +47 -0
- data/lib/llm/evals/scoring/schema_validator.rb +102 -0
- data/lib/llm/evals/scoring/scorer.rb +49 -0
- data/lib/llm/evals/scoring/weighted_score.rb +148 -0
- data/lib/llm/evals/scoring.rb +9 -0
- data/lib/llm/evals/stats/call_stats.rb +91 -0
- data/lib/llm/evals/stats/usage.rb +46 -0
- data/lib/llm/evals/stats.rb +29 -0
- data/lib/llm/evals/testing/echo_adapter.rb +14 -9
- data/lib/llm/evals/testing/fake_model.rb +8 -6
- data/lib/llm/evals/testing/fake_provider.rb +39 -8
- data/lib/llm/evals/testing.rb +9 -0
- data/lib/llm/evals/trackers/base.rb +110 -0
- data/lib/llm/evals/trackers/braintrust.rb +40 -3
- data/lib/llm/evals/trackers/local.rb +38 -2
- data/lib/llm/evals/trackers/logfire.rb +56 -3
- data/lib/llm/evals/trackers/registry.rb +29 -7
- data/lib/llm/evals/trackers.rb +9 -0
- data/lib/llm/evals/version.rb +3 -1
- data/lib/llm/evals.rb +17 -7
- metadata +91 -24
- data/lib/llm/evals/adapter.rb +0 -47
- data/lib/llm/evals/case.rb +0 -36
- data/lib/llm/evals/completion.rb +0 -11
- data/lib/llm/evals/configuration.rb +0 -173
- data/lib/llm/evals/contender.rb +0 -31
- data/lib/llm/evals/discovery.rb +0 -95
- data/lib/llm/evals/experiment.rb +0 -158
- data/lib/llm/evals/helper.rb +0 -98
- data/lib/llm/evals/metric.rb +0 -26
- data/lib/llm/evals/model.rb +0 -55
- data/lib/llm/evals/reply.rb +0 -18
- data/lib/llm/evals/run_result.rb +0 -14
- data/lib/llm/evals/schema_validator.rb +0 -84
- data/lib/llm/evals/score.rb +0 -26
- data/lib/llm/evals/scorer.rb +0 -45
- data/lib/llm/evals/tracker.rb +0 -80
- data/lib/llm/evals/usage.rb +0 -33
- data/lib/llm/evals/verify.rb +0 -49
- data/lib/llm/evals/warnings.rb +0 -98
- data/lib/llm/evals/weighted_score.rb +0 -116
- /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "cgi"
|
|
4
4
|
require "erb"
|
|
5
|
+
require "forwardable"
|
|
5
6
|
require "json"
|
|
6
7
|
|
|
7
8
|
module LLM
|
|
@@ -11,18 +12,31 @@ module LLM
|
|
|
11
12
|
# Builds a self-contained HTML report for completed eval runs.
|
|
12
13
|
# Only contenders with full, error-free coverage can lead the report.
|
|
13
14
|
class HTML
|
|
15
|
+
extend Forwardable
|
|
16
|
+
include Format
|
|
17
|
+
|
|
14
18
|
# Directory containing the report template and its embedded assets.
|
|
15
|
-
TEMPLATE_DIRECTORY = File.expand_path("
|
|
19
|
+
TEMPLATE_DIRECTORY = File.expand_path("../templates", __dir__)
|
|
16
20
|
|
|
21
|
+
# Shorthand for building one and rendering it.
|
|
22
|
+
#
|
|
17
23
|
# @param report [JSON::Loaded] decoded from a saved JSON snapshot
|
|
24
|
+
# @param stylesheet_path [String, nil] link to this stylesheet; nil embeds it in the page
|
|
25
|
+
# @param script_path [String, nil] link to this script; nil embeds it in the page
|
|
18
26
|
# @return [String]
|
|
19
27
|
def self.render(report, stylesheet_path: nil, script_path: nil)
|
|
20
28
|
new(report, stylesheet_path:, script_path:).render
|
|
21
29
|
end
|
|
22
30
|
|
|
31
|
+
# What the page is built from, and the assets it links instead of embedding.
|
|
32
|
+
# `report` is a {JSON::Loaded}; the two paths are Strings, or nil to embed.
|
|
33
|
+
#
|
|
34
|
+
# @return [JSON::Loaded, String, nil]
|
|
23
35
|
attr_reader :report, :stylesheet_path, :script_path
|
|
24
36
|
|
|
25
37
|
# @param report [JSON::Loaded] decoded from a saved JSON snapshot
|
|
38
|
+
# @param stylesheet_path [String, nil] link to this stylesheet; nil embeds it in the page
|
|
39
|
+
# @param script_path [String, nil] link to this script; nil embeds it in the page
|
|
26
40
|
def initialize(report, stylesheet_path: nil, script_path: nil)
|
|
27
41
|
@report = report
|
|
28
42
|
@stylesheet_path = stylesheet_path
|
|
@@ -33,220 +47,83 @@ module LLM
|
|
|
33
47
|
#
|
|
34
48
|
# @return [String]
|
|
35
49
|
def render
|
|
36
|
-
health = health_summary
|
|
37
|
-
standings = global_standings
|
|
38
|
-
datasets = dataset_reports
|
|
50
|
+
health = view.health_summary
|
|
51
|
+
standings = view.global_standings
|
|
52
|
+
datasets = view.dataset_reports
|
|
39
53
|
ERB.new(File.read(template_path), trim_mode: "-").result(binding)
|
|
40
54
|
end
|
|
41
55
|
|
|
42
56
|
private
|
|
43
57
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
# @return [Array<RunResult>]
|
|
60
|
-
def successful_results = results.select { it.score && !it.failed? && !it.reply&.error? }
|
|
61
|
-
|
|
62
|
-
# @return [Hash{Symbol => Object}] health counts and cost for the template
|
|
63
|
-
def health_summary
|
|
64
|
-
failed = results.count { !it.score || it.failed? || it.reply&.error? }
|
|
65
|
-
usage = total_usage
|
|
66
|
-
{
|
|
67
|
-
successful: successful_results.size,
|
|
68
|
-
total: results.size,
|
|
69
|
-
failed:,
|
|
70
|
-
input_tokens: tokens(usage.input_tokens),
|
|
71
|
-
output_tokens: tokens(usage.output_tokens),
|
|
72
|
-
cost: money(usage.cost_usd)
|
|
73
|
-
}
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# @return [Hash{Symbol => Object}] report entries and ranking after coverage checks
|
|
77
|
-
def global_standings
|
|
78
|
-
expected_keys = results.map { it.case.key }.uniq
|
|
79
|
-
entries = configuration_groups.map { |group| contender(group, expected_keys) }
|
|
80
|
-
complete = entries.select { it[:complete] }
|
|
81
|
-
assign_weighted_scores(complete)
|
|
82
|
-
ranked = complete.sort_by { ranking_value(it) || -1 }
|
|
83
|
-
|
|
84
|
-
{
|
|
85
|
-
entries:,
|
|
86
|
-
expected_count: expected_keys.length,
|
|
87
|
-
ranked:,
|
|
88
|
-
leader: ranked.last,
|
|
89
|
-
best_coverage: entries.max_by { it[:successful] }
|
|
90
|
-
}
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# @return [Array<Hash{Symbol => Object}>] data for each dataset section
|
|
94
|
-
def dataset_reports
|
|
95
|
-
summaries.group_by(&:dataset).sort.map do |dataset, group|
|
|
96
|
-
case_keys = group.flat_map { it.results.map { it.case.key } }.uniq
|
|
97
|
-
entries = group.map { contender([it], case_keys) }
|
|
98
|
-
complete = entries.select { it[:complete] }
|
|
99
|
-
assign_weighted_scores(complete)
|
|
100
|
-
|
|
101
|
-
{
|
|
102
|
-
name: dataset,
|
|
103
|
-
summaries: group,
|
|
104
|
-
case_keys:,
|
|
105
|
-
winner: complete.max_by { ranking_value(it) || -1 },
|
|
106
|
-
entries: entries.sort_by { ranking_value(it) || -1 }.reverse,
|
|
107
|
-
cases: group.flat_map(&:results).group_by { it.case.key }.values.map(&:first)
|
|
108
|
-
.sort_by { [it.case.variant, it.case.number || 0, it.case.id] }
|
|
109
|
-
}
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# @param kase [Case]
|
|
114
|
-
# @param group [Array<Experiment::Summary>]
|
|
115
|
-
# @return [Array<Hash{Symbol => Object}]] runs and display values for one case
|
|
116
|
-
def case_results(kase, group)
|
|
117
|
-
group.flat_map(&:results).select { it.case.key == kase.key }.map do |run|
|
|
118
|
-
status = if run.failed?
|
|
119
|
-
run.error
|
|
120
|
-
elsif run.reply&.error?
|
|
121
|
-
run.reply.error
|
|
122
|
-
elsif run.score
|
|
123
|
-
percent(run.score.value)
|
|
124
|
-
else
|
|
125
|
-
"No score"
|
|
126
|
-
end
|
|
127
|
-
details = run.score&.diagnostics
|
|
128
|
-
details = { "error" => run.error } if run.failed?
|
|
129
|
-
{ run:, status:, details: details || {} }
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# @param group [Array<Experiment::Summary>]
|
|
134
|
-
# @param expected_keys [Array<String>]
|
|
135
|
-
# @return [Hash{Symbol => Object}] model coverage and score inputs
|
|
136
|
-
def contender(group, expected_keys)
|
|
137
|
-
group_results = group.flat_map(&:results).uniq { it.case.key }
|
|
138
|
-
successful = group_results.select { it.score && !it.failed? && !it.reply&.error? }
|
|
139
|
-
errors = group_results.count { !it.score || it.failed? || it.reply&.error? }
|
|
140
|
-
metric_values = metrics.to_h do |metric|
|
|
141
|
-
values = group_results.filter_map { it.score&.metrics&.fetch(metric.name, nil) }
|
|
142
|
-
[metric.name, mean(values)]
|
|
143
|
-
end
|
|
144
|
-
score_values = group_results.filter_map { it.score&.value unless it.failed? || it.reply&.error? }
|
|
145
|
-
cost = group.sum { it.usage.cost_usd }
|
|
146
|
-
row = metric_values.merge(
|
|
147
|
-
"score" => mean(score_values),
|
|
148
|
-
"errors" => errors,
|
|
149
|
-
"time" => group.sum(&:duration),
|
|
150
|
-
"cost" => cost
|
|
151
|
-
)
|
|
152
|
-
successful_keys = successful.map { it.case.key }.uniq
|
|
153
|
-
|
|
154
|
-
{
|
|
155
|
-
model: group.first.model,
|
|
156
|
-
successful: successful_keys.length,
|
|
157
|
-
total: expected_keys.length,
|
|
158
|
-
errors:,
|
|
159
|
-
cost:,
|
|
160
|
-
input_tokens: group.sum { it.usage.input_tokens },
|
|
161
|
-
output_tokens: group.sum { it.usage.output_tokens },
|
|
162
|
-
row:,
|
|
163
|
-
complete: expected_keys.length.positive? && successful_keys.sort == expected_keys.uniq.sort && errors.zero?
|
|
164
|
-
}
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# @return [Array<Array<Experiment::Summary>>]
|
|
168
|
-
def configuration_groups
|
|
169
|
-
summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# @param entries [Array<Hash{Symbol => Object}>]
|
|
173
|
-
# @return [void]
|
|
174
|
-
def assign_weighted_scores(entries)
|
|
175
|
-
scores = if weighted_score
|
|
176
|
-
weighted_score.call(entries.map { it[:row] })
|
|
177
|
-
else
|
|
178
|
-
entries.map { it[:row][primary_metric] }
|
|
179
|
-
end
|
|
180
|
-
entries.zip(scores).each { |entry, score| entry[:ranking] = score }
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# @param entry [Hash{Symbol => Object}]
|
|
184
|
-
# @return [Float, nil]
|
|
185
|
-
def ranking_value(entry) = entry[:ranking]
|
|
186
|
-
|
|
187
|
-
# @return [String]
|
|
188
|
-
def primary_metric = metrics.find(&:primary)&.name || "score"
|
|
189
|
-
|
|
190
|
-
# @return [String]
|
|
191
|
-
def weight_description
|
|
192
|
-
weighted_score ? "Weighted score from the lineup; chart order runs worst to best." : "Ranked by #{primary_metric}; chart order runs worst to best."
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# @return [Usage]
|
|
196
|
-
def total_usage = summaries.map(&:usage).reduce(Usage.zero, :+)
|
|
58
|
+
# Template helpers that {ViewModel} answers; the ERB calls them through this binding.
|
|
59
|
+
def_delegators :view,
|
|
60
|
+
:report_title,
|
|
61
|
+
:configuration_name,
|
|
62
|
+
:summaries,
|
|
63
|
+
:weighted_score,
|
|
64
|
+
:case_results,
|
|
65
|
+
:ranking_value,
|
|
66
|
+
:primary_metric,
|
|
67
|
+
:weight_description
|
|
68
|
+
|
|
69
|
+
# The template's source of data, built once per report.
|
|
70
|
+
#
|
|
71
|
+
# @return [ViewModel]
|
|
72
|
+
def view = @view ||= ViewModel.new(report)
|
|
197
73
|
|
|
198
|
-
#
|
|
74
|
+
# The id's first segment is called the provider here; an id with no slash fills both.
|
|
75
|
+
#
|
|
76
|
+
# @param model [Models::Model]
|
|
199
77
|
# @return [Array<String>] HTML-escaped provider and model names
|
|
200
78
|
def model_parts(model)
|
|
201
79
|
provider, name = model.id.to_s.split("/", 2)
|
|
202
80
|
[escape(provider), escape(name || provider)]
|
|
203
81
|
end
|
|
204
82
|
|
|
205
|
-
#
|
|
83
|
+
# For the template's model rows.
|
|
84
|
+
#
|
|
85
|
+
# @param model [Models::Model]
|
|
206
86
|
# @return [String] escaped effort setting, or `default`
|
|
207
|
-
def effort_label(model) = escape(model.
|
|
87
|
+
def effort_label(model) = escape(model.effort_label)
|
|
208
88
|
|
|
209
|
-
#
|
|
89
|
+
# For the template's model rows.
|
|
90
|
+
#
|
|
91
|
+
# @param model [Models::Model]
|
|
210
92
|
# @return [String] escaped temperature setting, or `default`
|
|
211
|
-
def temperature_label(model) = escape(model.
|
|
212
|
-
|
|
213
|
-
# @param values [Array<Numeric>]
|
|
214
|
-
# @return [Float, nil]
|
|
215
|
-
def mean(values) = values.empty? ? nil : values.sum.fdiv(values.size)
|
|
216
|
-
|
|
217
|
-
# @param value [Numeric, nil]
|
|
218
|
-
# @return [String]
|
|
219
|
-
def percent(value) = value.nil? ? "—" : format("%.1f%%", value * 100)
|
|
220
|
-
|
|
221
|
-
# @param value [Numeric]
|
|
222
|
-
# @return [String]
|
|
223
|
-
def money(value)
|
|
224
|
-
cents = (value.to_s.to_r * 100).ceil
|
|
225
|
-
format("$%<dollars>d.%<cents>02d", dollars: cents / 100, cents: cents % 100)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# @param value [Numeric]
|
|
229
|
-
# @return [String]
|
|
230
|
-
def tokens(value) = value.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
93
|
+
def temperature_label(model) = escape(model.temperature_label)
|
|
231
94
|
|
|
95
|
+
# For the expected answer and case details on the page.
|
|
96
|
+
#
|
|
232
97
|
# @param value [Object]
|
|
233
98
|
# @return [String]
|
|
234
99
|
def pretty(value) = ::JSON.pretty_generate(value)
|
|
235
100
|
|
|
101
|
+
# Everything the template prints from data goes through this.
|
|
102
|
+
#
|
|
236
103
|
# @param value [Object]
|
|
237
104
|
# @return [String] HTML-escaped text
|
|
238
105
|
def escape(value) = CGI.escapeHTML(value.to_s)
|
|
239
106
|
|
|
107
|
+
# The packaged stylesheet's text, for embedding.
|
|
108
|
+
#
|
|
240
109
|
# @return [String]
|
|
241
110
|
def stylesheet = asset("html_report.css")
|
|
242
111
|
|
|
112
|
+
# A link when the report ships its stylesheet alongside; otherwise embedded, so the file stands alone.
|
|
113
|
+
#
|
|
114
|
+
# @return [String] an HTML tag
|
|
243
115
|
def stylesheet_tag
|
|
244
116
|
stylesheet_path ? %(<link rel="stylesheet" href="#{escape(stylesheet_path)}">) : "<style>#{stylesheet}</style>"
|
|
245
117
|
end
|
|
246
118
|
|
|
119
|
+
# The packaged script's text, which sorts the tables, for embedding.
|
|
120
|
+
#
|
|
247
121
|
# @return [String]
|
|
248
122
|
def sorting_script = asset("html_report.js")
|
|
249
123
|
|
|
124
|
+
# A link when the report ships its script alongside; otherwise embedded, so the file stands alone.
|
|
125
|
+
#
|
|
126
|
+
# @return [String] an HTML tag
|
|
250
127
|
def script_tag
|
|
251
128
|
script_path ? %(<script src="#{escape(script_path)}"></script>) : "<script>#{sorting_script}</script>"
|
|
252
129
|
end
|
|
@@ -257,6 +134,8 @@ module LLM
|
|
|
257
134
|
# @return [String]
|
|
258
135
|
def asset(name) = File.read(File.join(TEMPLATE_DIRECTORY, name))
|
|
259
136
|
|
|
137
|
+
# Where the page's ERB source lives.
|
|
138
|
+
#
|
|
260
139
|
# @return [String]
|
|
261
140
|
def template_path = File.join(TEMPLATE_DIRECTORY, "html_report.html.erb")
|
|
262
141
|
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Render
|
|
6
|
+
module Reports
|
|
7
|
+
class JSON
|
|
8
|
+
# A snapshot's document back into runs, one mapper for each of {Encoder}'s.
|
|
9
|
+
class Decoder
|
|
10
|
+
# Looked up by name for each saved model.
|
|
11
|
+
#
|
|
12
|
+
# @param providers [Providers::Registry]
|
|
13
|
+
def initialize(providers:)
|
|
14
|
+
@providers = providers
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Parses a document, checking its format and version before reading anything else.
|
|
18
|
+
#
|
|
19
|
+
# @param source [String] report JSON, not a path
|
|
20
|
+
# @return [Loaded]
|
|
21
|
+
# @raise [KeyError] when a required field is missing
|
|
22
|
+
# @raise [Error] for an unknown format/version or provider
|
|
23
|
+
def call(source)
|
|
24
|
+
document = ::JSON.parse(source)
|
|
25
|
+
unless document["format"] == FORMAT && document["version"] == VERSION
|
|
26
|
+
raise Error, "unsupported report format version (expected #{FORMAT} v#{VERSION})"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
metrics = document.fetch("metrics").map { metric(it) }
|
|
30
|
+
Loaded.new(suite: document.fetch("suite", "llm-evals"),
|
|
31
|
+
generated_at: document["generated_at"],
|
|
32
|
+
run_options: document.fetch("run_options", {}),
|
|
33
|
+
summaries: document.fetch("summaries").map { summary(it) },
|
|
34
|
+
metrics:,
|
|
35
|
+
weighted_score: weighted_score(document["weights"], metrics))
|
|
36
|
+
rescue ::JSON::ParserError => e
|
|
37
|
+
raise Error, "invalid report JSON: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @param data [Hash{String => Object}] one saved metric
|
|
43
|
+
# @return [Scoring::Metric]
|
|
44
|
+
def metric(data)
|
|
45
|
+
Scoring::Metric.new(name: data.fetch("name"),
|
|
46
|
+
description: data.fetch("description", ""),
|
|
47
|
+
aggregate: data.fetch("aggregate", "mean").to_sym,
|
|
48
|
+
primary: data.fetch("primary", false))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Rebuilds the lineup's weights, when it set any.
|
|
52
|
+
#
|
|
53
|
+
# @param weights [Hash{String => Numeric}, nil] as saved
|
|
54
|
+
# @param metrics [Array<Scoring::Metric>] the snapshot's
|
|
55
|
+
# @return [Scoring::WeightedScore, nil] nil with no weights
|
|
56
|
+
def weighted_score(weights, metrics)
|
|
57
|
+
Scoring::WeightedScore.new(weights, metrics: metrics.map(&:name)) if weights && !weights.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One saved experiment, whose results all share a {SavedExperiment} in place of the real one.
|
|
61
|
+
#
|
|
62
|
+
# @param data [Hash{String => Object}] one saved summary
|
|
63
|
+
# @return [Actions::Experiment::Summary]
|
|
64
|
+
def summary(data)
|
|
65
|
+
model = model(data.fetch("model"))
|
|
66
|
+
experiment = SavedExperiment.new(model:)
|
|
67
|
+
Actions::Experiment::Summary.new(model:,
|
|
68
|
+
dataset: data.fetch("dataset"),
|
|
69
|
+
name: data.fetch("name"),
|
|
70
|
+
means: data.fetch("means"),
|
|
71
|
+
results: data.fetch("results").map { result(it, experiment) },
|
|
72
|
+
errors: data.fetch("errors", []),
|
|
73
|
+
duration: data.fetch("duration", 0.0),
|
|
74
|
+
permalink: data["permalink"],
|
|
75
|
+
usage: Stats::Usage.from(data["usage"]))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param data [Hash{String => Object}] the saved id, provider and settings
|
|
79
|
+
# @return [Models::Model]
|
|
80
|
+
# @raise [Error] when no configured provider has the saved name
|
|
81
|
+
def model(data)
|
|
82
|
+
provider = @providers.find { it.name == data.fetch("provider") }
|
|
83
|
+
raise Error, "report uses unknown provider #{data.fetch("provider").inspect}" unless provider
|
|
84
|
+
|
|
85
|
+
Models::Model.new(id: data.fetch("id"), provider:, settings: data.fetch("settings", {}))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param data [Hash{String => Object}] one saved run
|
|
89
|
+
# @param experiment [SavedExperiment] what ran it
|
|
90
|
+
# @return [Results::RunResult]
|
|
91
|
+
def result(data, experiment)
|
|
92
|
+
Results::RunResult.new(experiment:,
|
|
93
|
+
case: kase(data.fetch("case")),
|
|
94
|
+
reply: reply(data["reply"]),
|
|
95
|
+
score: score(data["score"]),
|
|
96
|
+
duration: data.fetch("duration", 0.0),
|
|
97
|
+
error: data["error"])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The case's directory is not saved, so its path comes back empty.
|
|
101
|
+
#
|
|
102
|
+
# @param data [Hash{String => Object}] one saved case
|
|
103
|
+
# @return [Datasets::Case]
|
|
104
|
+
def kase(data)
|
|
105
|
+
Datasets::Case.new(id: data.fetch("id"),
|
|
106
|
+
dataset: data.fetch("dataset"),
|
|
107
|
+
variant: data.fetch("variant"),
|
|
108
|
+
input: data.fetch("input"),
|
|
109
|
+
expected: data["expected"],
|
|
110
|
+
path: "",
|
|
111
|
+
metadata: (data["metadata"] || {}).transform_keys(&:to_sym))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param data [Hash{String => Object}, nil] one saved reply
|
|
115
|
+
# @return [Results::Reply, nil] nil when the run saved none
|
|
116
|
+
def reply(data)
|
|
117
|
+
return unless data
|
|
118
|
+
|
|
119
|
+
Results::Reply.new(output: data["output"], raw: data["raw"], usage: data["usage"], error: data["error"], metadata: data["metadata"] || {})
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param data [Hash{String => Object}, nil] one saved score
|
|
123
|
+
# @return [Results::Score, nil] nil when the run saved none
|
|
124
|
+
def score(data)
|
|
125
|
+
return unless data
|
|
126
|
+
|
|
127
|
+
Results::Score.new(value: data.fetch("value"),
|
|
128
|
+
metrics: data["metrics"] || {},
|
|
129
|
+
outcomes: data["outcomes"] || {},
|
|
130
|
+
diagnostics: data["diagnostics"] || {})
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
module Render
|
|
8
|
+
module Reports
|
|
9
|
+
class JSON
|
|
10
|
+
# Completed runs into the snapshot's document. {Decoder} reads it back.
|
|
11
|
+
class Encoder
|
|
12
|
+
# Does nothing until {#call}.
|
|
13
|
+
#
|
|
14
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
15
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
16
|
+
# @param weighted_score [Scoring::WeightedScore, nil]
|
|
17
|
+
# @param run_options [Hash]
|
|
18
|
+
# @param suite [String]
|
|
19
|
+
def initialize(summaries, metrics:, weighted_score:, run_options:, suite:)
|
|
20
|
+
@summaries = summaries
|
|
21
|
+
@metrics = metrics
|
|
22
|
+
@weighted_score = weighted_score
|
|
23
|
+
@run_options = run_options
|
|
24
|
+
@suite = suite
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Stamps the document with the current time. Call stats are included for other
|
|
28
|
+
# readers of the file; {Decoder} ignores them and recalculates.
|
|
29
|
+
#
|
|
30
|
+
# @return [String]
|
|
31
|
+
def call
|
|
32
|
+
::JSON.pretty_generate(
|
|
33
|
+
format: FORMAT,
|
|
34
|
+
version: VERSION,
|
|
35
|
+
suite: @suite,
|
|
36
|
+
generated_at: Time.now.iso8601,
|
|
37
|
+
run_options: plain(@run_options),
|
|
38
|
+
metrics: @metrics.map { { name: it.name, description: it.description, aggregate: it.aggregate, primary: it.primary } },
|
|
39
|
+
weights: @weighted_score&.weights,
|
|
40
|
+
call_stats: Stats::CallStats.for_run(@summaries, @run_options).rows.map(&:to_h),
|
|
41
|
+
summaries: @summaries.map { summary(it) }
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Turns symbols into strings at every depth, so what is written reads back the same.
|
|
48
|
+
#
|
|
49
|
+
# @param value [Object] run options, or anything inside them
|
|
50
|
+
# @return [Object] the same shape, with no Symbols
|
|
51
|
+
def plain(value)
|
|
52
|
+
case value
|
|
53
|
+
when Hash then value.to_h { |key, item| [key.to_s, plain(item)] }
|
|
54
|
+
when Array then value.map { plain(it) }
|
|
55
|
+
when Symbol then value.to_s
|
|
56
|
+
else value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param summary [Actions::Experiment::Summary]
|
|
61
|
+
# @return [Hash{Symbol => Object}]
|
|
62
|
+
def summary(summary)
|
|
63
|
+
{
|
|
64
|
+
model: { id: summary.model.id, provider: summary.model.provider.name, settings: summary.model.settings },
|
|
65
|
+
dataset: summary.dataset,
|
|
66
|
+
name: summary.name,
|
|
67
|
+
means: summary.means,
|
|
68
|
+
errors: summary.errors,
|
|
69
|
+
duration: summary.duration,
|
|
70
|
+
permalink: summary.permalink,
|
|
71
|
+
usage: summary.usage.to_h,
|
|
72
|
+
results: summary.results.map { result(it) }
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param result [Results::RunResult]
|
|
77
|
+
# @return [Hash{Symbol => Object}]
|
|
78
|
+
def result(result)
|
|
79
|
+
{ case: kase(result.case), reply: reply(result.reply), score: score(result.score), duration: result.duration, error: result.error }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Omits the case's path, keeping the snapshot portable.
|
|
83
|
+
#
|
|
84
|
+
# @param kase [Datasets::Case]
|
|
85
|
+
# @return [Hash{Symbol => Object}]
|
|
86
|
+
def kase(kase)
|
|
87
|
+
{ id: kase.id, dataset: kase.dataset, variant: kase.variant, input: kase.input, expected: kase.expected, metadata: kase.metadata }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param reply [Results::Reply, nil]
|
|
91
|
+
# @return [Hash{Symbol => Object}, nil] nil for a run that raised
|
|
92
|
+
def reply(reply)
|
|
93
|
+
reply && { output: reply.output, raw: reply.raw, usage: reply.usage.to_h, error: reply.error, metadata: reply.metadata }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param score [Results::Score, nil]
|
|
97
|
+
# @return [Hash{Symbol => Object}, nil] nil for an unscored run
|
|
98
|
+
def score(score)
|
|
99
|
+
score && { value: score.value, metrics: score.metrics, outcomes: score.outcomes, diagnostics: score.diagnostics }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|