completion-kit 0.28.46 → 0.29.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/app/assets/javascripts/completion_kit/application.js +52 -23
- data/app/assets/stylesheets/completion_kit/application.css +44 -14
- data/app/controllers/completion_kit/api/v1/metric_versions_controller.rb +2 -0
- data/app/controllers/completion_kit/api/v1/metrics_controller.rb +1 -1
- data/app/controllers/completion_kit/api/v1/runs_controller.rb +4 -1
- data/app/controllers/completion_kit/metrics_controller.rb +48 -46
- data/app/controllers/completion_kit/runs_controller.rb +4 -1
- data/app/helpers/completion_kit/application_helper.rb +99 -45
- data/app/jobs/completion_kit/check_review_job.rb +27 -6
- data/app/models/completion_kit/metric.rb +103 -28
- data/app/models/completion_kit/metric_version.rb +16 -1
- data/app/models/completion_kit/review.rb +2 -0
- data/app/models/completion_kit/run.rb +19 -8
- data/app/services/completion_kit/checks/contains.rb +2 -8
- data/app/services/completion_kit/checks/equals.rb +2 -7
- data/app/services/completion_kit/checks/expected_resolver.rb +17 -3
- data/app/services/completion_kit/checks/flag.rb +13 -0
- data/app/services/completion_kit/checks/json_path_equals.rb +2 -15
- data/app/services/completion_kit/checks/labels.rb +104 -0
- data/app/services/completion_kit/checks/length_bounds.rb +4 -4
- data/app/services/completion_kit/checks/list_overlap.rb +96 -0
- data/app/services/completion_kit/checks/not_contains.rb +2 -8
- data/app/services/completion_kit/checks/numeric_bounds.rb +22 -0
- data/app/services/completion_kit/checks/numeric_equals.rb +37 -0
- data/app/services/completion_kit/checks/numeric_value.rb +45 -0
- data/app/services/completion_kit/checks/regex.rb +2 -2
- data/app/services/completion_kit/checks/registry.rb +107 -4
- data/app/services/completion_kit/checks/result.rb +5 -1
- data/app/services/completion_kit/checks/target_resolver.rb +43 -8
- data/app/services/completion_kit/checks/textual.rb +19 -0
- data/app/services/completion_kit/mcp_tools/metrics.rb +21 -7
- data/app/services/completion_kit/promptfoo_importer.rb +4 -4
- data/app/services/completion_kit/run_comparison.rb +6 -0
- data/app/views/completion_kit/api_reference/_body.html.erb +7 -3
- data/app/views/completion_kit/metrics/_check_spec.html.erb +5 -11
- data/app/views/completion_kit/metrics/_form.html.erb +62 -47
- data/app/views/completion_kit/responses/show.html.erb +1 -1
- data/app/views/completion_kit/runs/_compare_score.html.erb +1 -1
- data/app/views/completion_kit/runs/_form.html.erb +9 -3
- data/app/views/completion_kit/runs/_response_row.html.erb +3 -2
- data/app/views/completion_kit/runs/_row.html.erb +10 -8
- data/app/views/completion_kit/runs/_run_config.html.erb +1 -1
- data/app/views/completion_kit/runs/_status_panel.html.erb +2 -2
- data/app/views/completion_kit/runs/compare.html.erb +10 -6
- data/app/views/completion_kit/shared/_branded_select.html.erb +1 -1
- data/config/locales/en.yml +2 -0
- data/db/migrate/20260903000001_add_check_score_to_completion_kit_reviews.rb +5 -0
- data/lib/completion_kit/version.rb +1 -1
- metadata +9 -1
|
@@ -25,6 +25,7 @@ module CompletionKit
|
|
|
25
25
|
status: "succeeded",
|
|
26
26
|
passed: result.passed,
|
|
27
27
|
ai_score: nil,
|
|
28
|
+
check_score: result.score,
|
|
28
29
|
ai_feedback: result.detail,
|
|
29
30
|
error_provider: nil, error_class: nil, error_status: nil, error_message: nil
|
|
30
31
|
)
|
|
@@ -36,23 +37,43 @@ module CompletionKit
|
|
|
36
37
|
private
|
|
37
38
|
|
|
38
39
|
def evaluate(response, config)
|
|
39
|
-
|
|
40
|
+
kind = config["check_kind"]
|
|
41
|
+
target_value = resolve_target(response, config, kind)
|
|
40
42
|
if target_value.equal?(Checks::TargetResolver::UNRESOLVED)
|
|
41
|
-
return
|
|
43
|
+
return ungraded(kind, "could not resolve target")
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
kind = config["check_kind"]
|
|
45
46
|
if config["compare_to"] == "expected" && Checks::Registry.compares_value?(kind)
|
|
46
|
-
expected_value =
|
|
47
|
+
expected_value = resolve_expected(response, config, kind)
|
|
47
48
|
if expected_value.equal?(Checks::ExpectedResolver::UNRESOLVED)
|
|
48
|
-
return
|
|
49
|
+
return ungraded(kind, "no expected value for this row")
|
|
49
50
|
end
|
|
50
|
-
config = config.merge(
|
|
51
|
+
config = config.merge(Checks::Registry.expected_key(kind) => expected_value)
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
Checks::Registry.fetch(kind).call(target_value, config)
|
|
54
55
|
end
|
|
55
56
|
|
|
57
|
+
def ungraded(kind, detail)
|
|
58
|
+
Checks::Result.new(passed: false, detail: detail, score: Checks::Registry.scores?(kind) ? 0.0 : nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_target(response, config, kind)
|
|
62
|
+
if Checks::Registry.raw_target?(kind)
|
|
63
|
+
Checks::TargetResolver.call_value(response, config)
|
|
64
|
+
else
|
|
65
|
+
Checks::TargetResolver.call(response, config)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_expected(response, config, kind)
|
|
70
|
+
if Checks::Registry.raw_expected?(kind)
|
|
71
|
+
Checks::ExpectedResolver.call_value(response, config)
|
|
72
|
+
else
|
|
73
|
+
Checks::ExpectedResolver.call(response, config)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
56
77
|
def record_terminal_failure!(error)
|
|
57
78
|
response = Response.find_by(id: @response_id)
|
|
58
79
|
return unless response
|
|
@@ -30,6 +30,7 @@ module CompletionKit
|
|
|
30
30
|
validate :metric_type_immutable_once_in_use, on: :update
|
|
31
31
|
|
|
32
32
|
before_validation :generate_key
|
|
33
|
+
before_validation :default_check_target, if: :check?
|
|
33
34
|
before_validation :normalize_rubric_bands, if: :llm_judge?
|
|
34
35
|
before_validation :set_defaults, if: :llm_judge?
|
|
35
36
|
|
|
@@ -94,6 +95,10 @@ module CompletionKit
|
|
|
94
95
|
RunMetric.exists?(metric_id: id) || reviews.exists? || metric_versions.exists?
|
|
95
96
|
end
|
|
96
97
|
|
|
98
|
+
def check_error_keys
|
|
99
|
+
errors.objects.select { |error| error.attribute == :check_config }.flat_map { |error| Array(error.options[:check_keys]) }.uniq
|
|
100
|
+
end
|
|
101
|
+
|
|
97
102
|
def as_json(options = {})
|
|
98
103
|
base = {
|
|
99
104
|
id: id, name: name, key: key, metric_type: metric_type,
|
|
@@ -129,67 +134,137 @@ module CompletionKit
|
|
|
129
134
|
|
|
130
135
|
kind = config["check_kind"]
|
|
131
136
|
unless CompletionKit::Checks::Registry.kinds.include?(kind)
|
|
132
|
-
|
|
137
|
+
add_check_error("needs #{check_label("check_kind")} to be one of #{CompletionKit::Checks::Registry.kinds.join(", ")}", "check_kind")
|
|
133
138
|
return
|
|
134
139
|
end
|
|
135
140
|
|
|
136
141
|
validate_check_target(config)
|
|
137
142
|
validate_check_compare_to(config, kind)
|
|
138
143
|
validate_check_required_keys(config, kind)
|
|
139
|
-
|
|
144
|
+
if refused_list_value?(config, kind)
|
|
145
|
+
add_check_error("needs #{check_label("value", kind)} to be a single value, since only list_overlap takes a list", "value")
|
|
146
|
+
else
|
|
147
|
+
validate_check_kind_rules(config, kind)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def refused_list_value?(config, kind)
|
|
152
|
+
config["value"].is_a?(Array) && kind != "list_overlap" && will_save_change_to_check_config?
|
|
140
153
|
end
|
|
141
154
|
|
|
142
155
|
def validate_check_compare_to(config, kind)
|
|
143
156
|
return unless config.key?("compare_to")
|
|
144
157
|
|
|
145
158
|
unless %w[constant expected].include?(config["compare_to"])
|
|
146
|
-
|
|
159
|
+
add_check_error("needs #{check_label("compare_to")} to be constant or expected", "compare_to")
|
|
147
160
|
return
|
|
148
161
|
end
|
|
149
162
|
if config["compare_to"] == "expected" && !CompletionKit::Checks::Registry.compares_value?(kind)
|
|
150
|
-
|
|
163
|
+
add_check_error("can grade against \"Each row's expected value\" only with #{CompletionKit::Checks::Registry.comparable_kinds.join(", ")}", "compare_to")
|
|
151
164
|
end
|
|
152
165
|
end
|
|
153
166
|
|
|
154
167
|
def validate_check_target(config)
|
|
155
168
|
target = config["target"].presence || "response_text"
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
169
|
+
path_given = config["target_path"].present?
|
|
170
|
+
if !CompletionKit::Checks::TargetResolver::TARGETS.include?(target)
|
|
171
|
+
add_check_error("needs #{check_label("target")} to be one of #{CompletionKit::Checks::TargetResolver::TARGETS.join(", ")}", "target")
|
|
172
|
+
elsif target == "json_path" && !path_given
|
|
173
|
+
add_check_error("needs #{check_label("target_path")} filled in to check a value from the response JSON", "target_path")
|
|
174
|
+
elsif target != "json_path" && path_given && will_save_change_to_check_config?
|
|
175
|
+
add_check_error("needs #{check_label("target")} to be \"#{CompletionKit::Checks::Labels.target("json_path")}\" when #{check_label("target_path")} is filled in", "target")
|
|
161
176
|
end
|
|
162
177
|
end
|
|
163
178
|
|
|
179
|
+
def default_check_target
|
|
180
|
+
config = check_config
|
|
181
|
+
return unless config.is_a?(Hash) && config["target"].blank? && config["target_path"].present? && will_save_change_to_check_config?
|
|
182
|
+
|
|
183
|
+
self.check_config = config.merge("target" => "json_path")
|
|
184
|
+
end
|
|
185
|
+
|
|
164
186
|
def validate_check_required_keys(config, kind)
|
|
165
187
|
required = CompletionKit::Checks::Registry.required_keys.fetch(kind)
|
|
166
|
-
required -=
|
|
188
|
+
required -= [CompletionKit::Checks::Registry.expected_key(kind)] if config["compare_to"] == "expected" && CompletionKit::Checks::Registry.compares_value?(kind)
|
|
167
189
|
required.each do |required_key|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
elsif config[required_key].to_s.strip.empty?
|
|
171
|
-
errors.add(:check_config, "#{required_key} is required")
|
|
172
|
-
end
|
|
190
|
+
missing = required_key == "expected" ? !config.key?("expected") : config[required_key].to_s.strip.empty?
|
|
191
|
+
add_check_error("needs #{check_label(required_key, kind)} filled in", required_key) if missing
|
|
173
192
|
end
|
|
174
193
|
end
|
|
175
194
|
|
|
176
195
|
def validate_check_kind_rules(config, kind)
|
|
177
196
|
case kind
|
|
178
197
|
when "regex"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
when "length_bounds"
|
|
185
|
-
min = config["min"]
|
|
186
|
-
max = config["max"]
|
|
187
|
-
if min.nil? && max.nil?
|
|
188
|
-
errors.add(:check_config, "length_bounds requires at least one of min or max")
|
|
189
|
-
elsif min && max && min.to_i > max.to_i
|
|
190
|
-
errors.add(:check_config, "min must be less than or equal to max")
|
|
191
|
-
end
|
|
198
|
+
validate_check_pattern(config)
|
|
199
|
+
when "list_overlap"
|
|
200
|
+
validate_check_list_overlap(config)
|
|
201
|
+
when "numeric_equals"
|
|
202
|
+
validate_check_numeric_equals(config)
|
|
192
203
|
end
|
|
204
|
+
validate_check_bounds(config, kind) if CompletionKit::Checks::Registry.bounded?(kind)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def validate_check_pattern(config)
|
|
208
|
+
Regexp.new(config["pattern"].to_s)
|
|
209
|
+
rescue RegexpError
|
|
210
|
+
add_check_error("needs #{check_label("pattern")} to be a valid regular expression", "pattern")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def validate_check_bounds(config, kind)
|
|
214
|
+
min = config["min"].to_s.strip
|
|
215
|
+
max = config["max"].to_s.strip
|
|
216
|
+
min_label = check_label("min", kind)
|
|
217
|
+
max_label = check_label("max", kind)
|
|
218
|
+
if min.empty? && max.empty?
|
|
219
|
+
add_check_error("needs #{min_label} or #{max_label} filled in", "min", "max")
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
min_value = CompletionKit::Checks::NumericValue.parse(min)
|
|
224
|
+
max_value = CompletionKit::Checks::NumericValue.parse(max)
|
|
225
|
+
not_numbers = { "min" => !min.empty? && min_value.nil?, "max" => !max.empty? && max_value.nil? }.select { |_key, bad| bad }.keys
|
|
226
|
+
if not_numbers.any?
|
|
227
|
+
add_check_error("needs #{min_label} and #{max_label} to be numbers", *not_numbers)
|
|
228
|
+
elsif min_value && max_value && min_value > max_value
|
|
229
|
+
add_check_error("needs #{min_label} to be no more than #{max_label}", "min", "max")
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def validate_check_list_overlap(config)
|
|
234
|
+
measures = CompletionKit::Checks::ListOverlap::MEASURES
|
|
235
|
+
if config["score_by"].present? && !measures.include?(config["score_by"])
|
|
236
|
+
add_check_error("needs #{check_label("score_by")} to be one of #{measures.join(", ")}", "score_by")
|
|
237
|
+
end
|
|
238
|
+
return if config["min"].to_s.strip.empty?
|
|
239
|
+
|
|
240
|
+
threshold = CompletionKit::Checks::NumericValue.parse(config["min"])
|
|
241
|
+
if threshold.nil? || threshold.negative? || threshold > 1
|
|
242
|
+
add_check_error("needs #{check_label("min", "list_overlap")} to be a number between 0 and 1", "min")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_check_numeric_equals(config)
|
|
247
|
+
validate_check_tolerance(config)
|
|
248
|
+
return if config["compare_to"] == "expected" || config["value"].to_s.strip.empty?
|
|
249
|
+
|
|
250
|
+
add_check_error("needs #{check_label("value", "numeric_equals")} to be a number", "value") if CompletionKit::Checks::NumericValue.parse(config["value"]).nil?
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def validate_check_tolerance(config)
|
|
254
|
+
return if config["tolerance"].to_s.strip.empty?
|
|
255
|
+
|
|
256
|
+
tolerance = CompletionKit::Checks::NumericEquals.tolerance_value(config["tolerance"])
|
|
257
|
+
if tolerance.nil? || tolerance.negative?
|
|
258
|
+
add_check_error("needs #{check_label("tolerance")} to be a number that is not negative, or a percentage like 2%", "tolerance")
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def add_check_error(message, *keys)
|
|
263
|
+
errors.add(:check_config, message, check_keys: keys)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def check_label(key, kind = nil)
|
|
267
|
+
"\"#{CompletionKit::Checks::Labels.field(key, kind)}\""
|
|
193
268
|
end
|
|
194
269
|
|
|
195
270
|
def set_defaults
|
|
@@ -85,12 +85,14 @@ module CompletionKit
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def publish!
|
|
88
|
+
config = publishable_check_config if check?
|
|
89
|
+
validate_check_config_on_metric!(config) if check?
|
|
88
90
|
MetricVersion.transaction do
|
|
89
91
|
self.class.where(metric_id: metric_id).where.not(id: id).update_all(current: false)
|
|
90
92
|
reload
|
|
91
93
|
update!(state: "published", current: true, published_at: published_at || Time.current)
|
|
92
94
|
if check?
|
|
93
|
-
metric.update_columns(metric_type: "check", check_config:
|
|
95
|
+
metric.update_columns(metric_type: "check", check_config: config)
|
|
94
96
|
else
|
|
95
97
|
metric.update_columns(
|
|
96
98
|
metric_type: "llm_judge",
|
|
@@ -123,6 +125,19 @@ module CompletionKit
|
|
|
123
125
|
|
|
124
126
|
private
|
|
125
127
|
|
|
128
|
+
def publishable_check_config
|
|
129
|
+
config = check_config.to_h.stringify_keys
|
|
130
|
+
return config if config["target_path"].blank?
|
|
131
|
+
return config.merge("target" => "json_path") if config["target"].blank?
|
|
132
|
+
|
|
133
|
+
config["target"] == "json_path" ? config : config.except("target_path")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_check_config_on_metric!(config)
|
|
137
|
+
metric.assign_attributes(metric_type: "check", check_config: config)
|
|
138
|
+
raise ActiveRecord::RecordInvalid, metric unless metric.valid?
|
|
139
|
+
end
|
|
140
|
+
|
|
126
141
|
def check_change_summary_against(previous)
|
|
127
142
|
return nil if check_config == previous.check_config
|
|
128
143
|
|
|
@@ -10,6 +10,7 @@ module CompletionKit
|
|
|
10
10
|
validates :metric_name, presence: true
|
|
11
11
|
validates :metric_version, presence: true
|
|
12
12
|
validates :ai_score, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 5 }, allow_nil: true
|
|
13
|
+
validates :check_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
|
|
13
14
|
|
|
14
15
|
before_validation :set_default_status
|
|
15
16
|
|
|
@@ -36,6 +37,7 @@ module CompletionKit
|
|
|
36
37
|
id: id, response_id: response_id, metric_id: metric_id,
|
|
37
38
|
metric_version_id: metric_version_id,
|
|
38
39
|
metric_name: metric_name, ai_score: ai_score, passed: passed,
|
|
40
|
+
check_score: check_score&.to_f,
|
|
39
41
|
ai_feedback: ai_feedback, status: status, attempts: attempts,
|
|
40
42
|
error: error_payload
|
|
41
43
|
}
|
|
@@ -10,7 +10,7 @@ module CompletionKit
|
|
|
10
10
|
status: "pending",
|
|
11
11
|
attempts: 0,
|
|
12
12
|
error_provider: nil, error_class: nil, error_status: nil, error_message: nil,
|
|
13
|
-
ai_score: nil, passed: nil, ai_feedback: nil
|
|
13
|
+
ai_score: nil, passed: nil, check_score: nil, ai_feedback: nil
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
16
|
belongs_to :prompt, optional: true
|
|
@@ -79,7 +79,9 @@ module CompletionKit
|
|
|
79
79
|
Arel.sql("COUNT(ai_score)"),
|
|
80
80
|
Arel.sql("SUM(CASE WHEN ai_score < #{low_score_ceiling} THEN 1 ELSE 0 END)"),
|
|
81
81
|
Arel.sql("COUNT(passed)"),
|
|
82
|
-
Arel.sql("SUM(CASE WHEN passed THEN 1 ELSE 0 END)")
|
|
82
|
+
Arel.sql("SUM(CASE WHEN passed THEN 1 ELSE 0 END)"),
|
|
83
|
+
Arel.sql("AVG(check_score)"),
|
|
84
|
+
Arel.sql("COUNT(check_score)")
|
|
83
85
|
)
|
|
84
86
|
metrics_by_run = metric_rows.group_by(&:first)
|
|
85
87
|
|
|
@@ -90,12 +92,12 @@ module CompletionKit
|
|
|
90
92
|
run.avg_score = stats && stats[:avg] ? stats[:avg].to_f.round(2) : nil
|
|
91
93
|
run.check_pass_rate = stats && stats[:resolved] > 0 ? (stats[:passed].to_f / stats[:resolved]).round(2) : nil
|
|
92
94
|
|
|
93
|
-
run.metric_averages = (metrics_by_run[run.id] || []).filter_map do |(_rid, name, avg, scored, low, resolved, passed)|
|
|
95
|
+
run.metric_averages = (metrics_by_run[run.id] || []).filter_map do |(_rid, name, avg, scored, low, resolved, passed, fraction_avg, fraction_count)|
|
|
94
96
|
if scored.to_i > 0
|
|
95
97
|
{name: name, avg: avg.to_f.round(1), count: scored.to_i, low_count: low.to_i}
|
|
96
98
|
elsif resolved.to_i > 0
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
check_summary(name, passed.to_i, resolved.to_i,
|
|
100
|
+
fraction_count.to_i > 0 ? fraction_avg.to_f : nil)
|
|
99
101
|
end
|
|
100
102
|
end
|
|
101
103
|
end
|
|
@@ -109,6 +111,14 @@ module CompletionKit
|
|
|
109
111
|
CompletionKit.config.medium_quality_threshold.to_f
|
|
110
112
|
end
|
|
111
113
|
|
|
114
|
+
def self.check_summary(name, passed, resolved, fraction_avg)
|
|
115
|
+
summary = { name: name, kind: "check", pass_rate: (passed.to_f / resolved).round(2),
|
|
116
|
+
count: resolved, low_count: resolved - passed }
|
|
117
|
+
return summary if fraction_avg.nil?
|
|
118
|
+
|
|
119
|
+
summary.merge(avg_check_score: fraction_avg.round(3))
|
|
120
|
+
end
|
|
121
|
+
|
|
112
122
|
TOP_SCORE = 5.0
|
|
113
123
|
CEILING_MEAN = 4.8
|
|
114
124
|
CEILING_TOP_SHARE = 0.9
|
|
@@ -271,9 +281,9 @@ module CompletionKit
|
|
|
271
281
|
resolved = reviews.reject { |r| r.passed.nil? }
|
|
272
282
|
next if resolved.empty?
|
|
273
283
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
284
|
+
fractions = reviews.filter_map { |r| r.check_score&.to_f }
|
|
285
|
+
self.class.check_summary(name, resolved.count { |r| r.passed == true }, resolved.length,
|
|
286
|
+
fractions.any? ? fractions.sum / fractions.length : nil)
|
|
277
287
|
end
|
|
278
288
|
end
|
|
279
289
|
end
|
|
@@ -446,6 +456,7 @@ module CompletionKit
|
|
|
446
456
|
metric_version_id: nil,
|
|
447
457
|
ai_score: nil,
|
|
448
458
|
passed: nil,
|
|
459
|
+
check_score: nil,
|
|
449
460
|
ai_feedback: nil,
|
|
450
461
|
error_provider: nil,
|
|
451
462
|
error_class: nil,
|
|
@@ -2,15 +2,9 @@ module CompletionKit
|
|
|
2
2
|
module Checks
|
|
3
3
|
class Contains
|
|
4
4
|
def call(target, config)
|
|
5
|
-
value = config
|
|
6
|
-
haystack = target.to_s
|
|
7
|
-
present = if config["case_sensitive"] == true
|
|
8
|
-
haystack.include?(value)
|
|
9
|
-
else
|
|
10
|
-
haystack.downcase.include?(value.downcase)
|
|
11
|
-
end
|
|
5
|
+
value, haystack = Textual.operands(target, config)
|
|
12
6
|
|
|
13
|
-
if
|
|
7
|
+
if Textual.include?(haystack, value, config)
|
|
14
8
|
Result.new(passed: true, detail: "contains #{value.inspect}")
|
|
15
9
|
else
|
|
16
10
|
Result.new(passed: false, detail: "does not contain #{value.inspect}")
|
|
@@ -2,14 +2,9 @@ module CompletionKit
|
|
|
2
2
|
module Checks
|
|
3
3
|
class Equals
|
|
4
4
|
def call(target, config)
|
|
5
|
-
actual = target
|
|
6
|
-
expected = config["value"].to_s
|
|
7
|
-
if config["trim"] == true
|
|
8
|
-
actual = actual.strip
|
|
9
|
-
expected = expected.strip
|
|
10
|
-
end
|
|
5
|
+
expected, actual = Textual.operands(target, config)
|
|
11
6
|
|
|
12
|
-
match = if config
|
|
7
|
+
match = if Flag.on?(config, "case_sensitive")
|
|
13
8
|
actual == expected
|
|
14
9
|
else
|
|
15
10
|
actual.casecmp?(expected)
|
|
@@ -4,13 +4,27 @@ module CompletionKit
|
|
|
4
4
|
UNRESOLVED = TargetResolver::UNRESOLVED
|
|
5
5
|
|
|
6
6
|
def self.call(response, config)
|
|
7
|
+
TargetResolver.stringify(resolve(response, config) { |raw| raw.to_s })
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.call_value(response, config)
|
|
11
|
+
resolve(response, config) { |raw| parse(raw) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.resolve(response, config)
|
|
7
15
|
raw = response.expected_output
|
|
8
16
|
return UNRESOLVED if raw.blank?
|
|
9
17
|
|
|
10
|
-
path = config["expected_path"]
|
|
11
|
-
return raw
|
|
18
|
+
path = TargetResolver.normalize_path(config["expected_path"])
|
|
19
|
+
return yield(raw) if path.empty?
|
|
20
|
+
|
|
21
|
+
TargetResolver.dig_json(raw.to_s, path)
|
|
22
|
+
end
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
def self.parse(raw)
|
|
25
|
+
JSON.parse(raw.to_s)
|
|
26
|
+
rescue JSON::ParserError
|
|
27
|
+
raw
|
|
14
28
|
end
|
|
15
29
|
end
|
|
16
30
|
end
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
module CompletionKit
|
|
2
2
|
module Checks
|
|
3
3
|
class JsonPathEquals
|
|
4
|
-
MISSING = Object.new
|
|
5
|
-
|
|
6
4
|
def call(target, config)
|
|
7
|
-
|
|
8
|
-
value = dig(parsed, config["json_path"].to_s)
|
|
5
|
+
value = TargetResolver.walk(JSON.parse(target.to_s), config["json_path"])
|
|
9
6
|
|
|
10
|
-
if value.equal?(
|
|
7
|
+
if value.equal?(TargetResolver::UNRESOLVED)
|
|
11
8
|
Result.new(passed: false, detail: "path #{config["json_path"]} not found")
|
|
12
9
|
elsif value == config["expected"]
|
|
13
10
|
Result.new(passed: true, detail: "#{config["json_path"]} == #{config["expected"].inspect}")
|
|
@@ -17,16 +14,6 @@ module CompletionKit
|
|
|
17
14
|
rescue JSON::ParserError
|
|
18
15
|
Result.new(passed: false, detail: "not valid JSON")
|
|
19
16
|
end
|
|
20
|
-
|
|
21
|
-
private
|
|
22
|
-
|
|
23
|
-
def dig(data, path)
|
|
24
|
-
path.split(".").reduce(data) do |node, key|
|
|
25
|
-
return MISSING unless node.is_a?(Hash) && node.key?(key)
|
|
26
|
-
|
|
27
|
-
node[key]
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
17
|
end
|
|
31
18
|
end
|
|
32
19
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module CompletionKit
|
|
2
|
+
module Checks
|
|
3
|
+
module Labels
|
|
4
|
+
KINDS = {
|
|
5
|
+
"contains" => "Contains a phrase",
|
|
6
|
+
"not_contains" => "Does not contain a phrase",
|
|
7
|
+
"equals" => "Equals exactly",
|
|
8
|
+
"regex" => "Matches a pattern",
|
|
9
|
+
"valid_json" => "Is valid JSON",
|
|
10
|
+
"json_path_equals" => "A JSON field equals a value",
|
|
11
|
+
"length_bounds" => "Length is within a range",
|
|
12
|
+
"list_overlap" => "How much of a list was found",
|
|
13
|
+
"numeric_bounds" => "A number is within a range",
|
|
14
|
+
"numeric_equals" => "A number is close enough"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
TARGETS = {
|
|
18
|
+
"response_text" => "The response text",
|
|
19
|
+
"input_data" => "The input row",
|
|
20
|
+
"json_path" => "A value from the response JSON"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
COMPARE_TO = {
|
|
24
|
+
"constant" => "A value you type",
|
|
25
|
+
"expected" => "Each row's expected value"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
SCORE_BY = {
|
|
29
|
+
"recall" => "Coverage: how much of the expected list it found",
|
|
30
|
+
"precision" => "Accuracy: how much of what it returned was expected",
|
|
31
|
+
"f1" => "Balance: coverage and accuracy weighed equally",
|
|
32
|
+
"jaccard" => "Overlap: shared items out of every item in either list"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
FIELDS = {
|
|
36
|
+
"check_kind" => "Rule",
|
|
37
|
+
"target" => "What to check",
|
|
38
|
+
"target_path" => "Path into the JSON",
|
|
39
|
+
"compare_to" => "Compare against",
|
|
40
|
+
"value" => "Text to look for",
|
|
41
|
+
"pattern" => "Pattern",
|
|
42
|
+
"json_path" => "JSON path",
|
|
43
|
+
"expected" => "Expected value",
|
|
44
|
+
"expected_path" => "Field in the answer key",
|
|
45
|
+
"score_by" => "How to score it",
|
|
46
|
+
"min" => "Minimum",
|
|
47
|
+
"max" => "Maximum",
|
|
48
|
+
"tolerance" => "How far off is still right",
|
|
49
|
+
"case_sensitive" => "Case sensitive",
|
|
50
|
+
"multiline" => "Multiline",
|
|
51
|
+
"trim" => "Trim whitespace"
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
FIELDS_BY_KIND = {
|
|
55
|
+
"length_bounds" => { "min" => "Shortest allowed", "max" => "Longest allowed" },
|
|
56
|
+
"numeric_bounds" => { "min" => "Lowest allowed", "max" => "Highest allowed" },
|
|
57
|
+
"list_overlap" => { "min" => "Lowest score that passes", "value" => "Expected list" },
|
|
58
|
+
"numeric_equals" => { "value" => "Expected number" }
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
HINTS = {
|
|
62
|
+
"score_by" => "Coverage ignores extra items, and accuracy ignores missing ones. Balance weighs the two equally. " \
|
|
63
|
+
"Overlap divides the shared items by every item in either list, so it never scores higher than the other three.",
|
|
64
|
+
"min" => "Leave blank for no minimum.",
|
|
65
|
+
"max" => "Leave blank for no maximum."
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
HINTS_BY_KIND = {
|
|
69
|
+
"list_overlap" => { "min" => "Give a value from 0 to 1, like 0.6 for 60%. Leave blank to require a perfect score." }
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
VALUES = { "compare_to" => COMPARE_TO, "score_by" => SCORE_BY }.freeze
|
|
73
|
+
|
|
74
|
+
def self.kind(kind)
|
|
75
|
+
KINDS.fetch(kind.to_s) { kind.to_s.humanize }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.target(target)
|
|
79
|
+
TARGETS.fetch(target.to_s) { target.to_s.humanize }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.field(key, kind = nil)
|
|
83
|
+
FIELDS_BY_KIND.dig(kind.to_s, key.to_s) || FIELDS.fetch(key.to_s) { key.to_s.humanize }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.hint(key, kind = nil)
|
|
87
|
+
HINTS_BY_KIND.dig(kind.to_s, key.to_s) || HINTS[key.to_s]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.value(key, value)
|
|
91
|
+
VALUES.fetch(key.to_s, {}).fetch(value.to_s, value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.worded?(key)
|
|
95
|
+
VALUES.key?(key.to_s)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.in_form_order(config)
|
|
99
|
+
order = FIELDS.keys
|
|
100
|
+
config.sort_by.with_index { |(key, _value), index| [order.index(key) || order.length, index] }.to_h
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -3,13 +3,13 @@ module CompletionKit
|
|
|
3
3
|
class LengthBounds
|
|
4
4
|
def call(target, config)
|
|
5
5
|
length = target.to_s.length
|
|
6
|
-
min = config["min"]
|
|
7
|
-
max = config["max"]
|
|
6
|
+
min = NumericValue.parse(config["min"])
|
|
7
|
+
max = NumericValue.parse(config["max"])
|
|
8
8
|
|
|
9
9
|
if min && length < min
|
|
10
|
-
Result.new(passed: false, detail: "length #{length} below min #{min}")
|
|
10
|
+
Result.new(passed: false, detail: "length #{length} below min #{NumericValue.format(min)}")
|
|
11
11
|
elsif max && length > max
|
|
12
|
-
Result.new(passed: false, detail: "length #{length} above max #{max}")
|
|
12
|
+
Result.new(passed: false, detail: "length #{length} above max #{NumericValue.format(max)}")
|
|
13
13
|
else
|
|
14
14
|
Result.new(passed: true, detail: "length #{length} within bounds")
|
|
15
15
|
end
|