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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/completion_kit/application.js +52 -23
  3. data/app/assets/stylesheets/completion_kit/application.css +44 -14
  4. data/app/controllers/completion_kit/api/v1/metric_versions_controller.rb +2 -0
  5. data/app/controllers/completion_kit/api/v1/metrics_controller.rb +1 -1
  6. data/app/controllers/completion_kit/api/v1/runs_controller.rb +4 -1
  7. data/app/controllers/completion_kit/metrics_controller.rb +48 -46
  8. data/app/controllers/completion_kit/runs_controller.rb +4 -1
  9. data/app/helpers/completion_kit/application_helper.rb +99 -45
  10. data/app/jobs/completion_kit/check_review_job.rb +27 -6
  11. data/app/models/completion_kit/metric.rb +103 -28
  12. data/app/models/completion_kit/metric_version.rb +16 -1
  13. data/app/models/completion_kit/review.rb +2 -0
  14. data/app/models/completion_kit/run.rb +19 -8
  15. data/app/services/completion_kit/checks/contains.rb +2 -8
  16. data/app/services/completion_kit/checks/equals.rb +2 -7
  17. data/app/services/completion_kit/checks/expected_resolver.rb +17 -3
  18. data/app/services/completion_kit/checks/flag.rb +13 -0
  19. data/app/services/completion_kit/checks/json_path_equals.rb +2 -15
  20. data/app/services/completion_kit/checks/labels.rb +104 -0
  21. data/app/services/completion_kit/checks/length_bounds.rb +4 -4
  22. data/app/services/completion_kit/checks/list_overlap.rb +96 -0
  23. data/app/services/completion_kit/checks/not_contains.rb +2 -8
  24. data/app/services/completion_kit/checks/numeric_bounds.rb +22 -0
  25. data/app/services/completion_kit/checks/numeric_equals.rb +37 -0
  26. data/app/services/completion_kit/checks/numeric_value.rb +45 -0
  27. data/app/services/completion_kit/checks/regex.rb +2 -2
  28. data/app/services/completion_kit/checks/registry.rb +107 -4
  29. data/app/services/completion_kit/checks/result.rb +5 -1
  30. data/app/services/completion_kit/checks/target_resolver.rb +43 -8
  31. data/app/services/completion_kit/checks/textual.rb +19 -0
  32. data/app/services/completion_kit/mcp_tools/metrics.rb +21 -7
  33. data/app/services/completion_kit/promptfoo_importer.rb +4 -4
  34. data/app/services/completion_kit/run_comparison.rb +6 -0
  35. data/app/views/completion_kit/api_reference/_body.html.erb +7 -3
  36. data/app/views/completion_kit/metrics/_check_spec.html.erb +5 -11
  37. data/app/views/completion_kit/metrics/_form.html.erb +62 -47
  38. data/app/views/completion_kit/responses/show.html.erb +1 -1
  39. data/app/views/completion_kit/runs/_compare_score.html.erb +1 -1
  40. data/app/views/completion_kit/runs/_form.html.erb +9 -3
  41. data/app/views/completion_kit/runs/_response_row.html.erb +3 -2
  42. data/app/views/completion_kit/runs/_row.html.erb +10 -8
  43. data/app/views/completion_kit/runs/_run_config.html.erb +1 -1
  44. data/app/views/completion_kit/runs/_status_panel.html.erb +2 -2
  45. data/app/views/completion_kit/runs/compare.html.erb +10 -6
  46. data/app/views/completion_kit/shared/_branded_select.html.erb +1 -1
  47. data/config/locales/en.yml +2 -0
  48. data/db/migrate/20260903000001_add_check_score_to_completion_kit_reviews.rb +5 -0
  49. data/lib/completion_kit/version.rb +1 -1
  50. 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
- target_value = Checks::TargetResolver.call(response, config)
40
+ kind = config["check_kind"]
41
+ target_value = resolve_target(response, config, kind)
40
42
  if target_value.equal?(Checks::TargetResolver::UNRESOLVED)
41
- return Checks::Result.new(passed: false, detail: "could not resolve target")
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 = Checks::ExpectedResolver.call(response, config)
47
+ expected_value = resolve_expected(response, config, kind)
47
48
  if expected_value.equal?(Checks::ExpectedResolver::UNRESOLVED)
48
- return Checks::Result.new(passed: false, detail: "no expected value for this row")
49
+ return ungraded(kind, "no expected value for this row")
49
50
  end
50
- config = config.merge("value" => expected_value)
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
- errors.add(:check_config, "check_kind must be one of #{CompletionKit::Checks::Registry.kinds.join(", ")}")
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
- validate_check_kind_rules(config, kind)
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
- errors.add(:check_config, "compare_to must be constant or expected")
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
- errors.add(:check_config, "compare_to expected only applies to contains, not_contains, or equals")
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
- unless CompletionKit::Checks::TargetResolver::TARGETS.include?(target)
157
- errors.add(:check_config, "target must be one of #{CompletionKit::Checks::TargetResolver::TARGETS.join(", ")}")
158
- end
159
- if target == "json_path" && config["target_path"].to_s.strip.empty?
160
- errors.add(:check_config, "target_path is required when target is json_path")
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 -= %w[value] if config["compare_to"] == "expected" && CompletionKit::Checks::Registry.compares_value?(kind)
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
- if required_key == "expected"
169
- errors.add(:check_config, "expected is required") unless config.key?("expected")
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
- begin
180
- Regexp.new(config["pattern"].to_s)
181
- rescue RegexpError
182
- errors.add(:check_config, "pattern is not a valid regular expression")
183
- end
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: 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
- {name: name, kind: "check", pass_rate: (passed.to_i.to_f / resolved.to_i).round(2),
98
- count: resolved.to_i, low_count: resolved.to_i - passed.to_i}
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
- passed = resolved.count { |r| r.passed == true }
275
- { name: name, kind: "check", pass_rate: (passed.to_f / resolved.length).round(2),
276
- count: resolved.length, low_count: resolved.length - passed }
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["value"].to_s
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 present
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.to_s
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["case_sensitive"] == true
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"].to_s.strip
11
- return raw.to_s if path.empty?
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
- TargetResolver.resolve_json_path(raw.to_s, path)
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
@@ -0,0 +1,13 @@
1
+ module CompletionKit
2
+ module Checks
3
+ module Flag
4
+ def self.on?(config, key)
5
+ cast(config[key])
6
+ end
7
+
8
+ def self.cast(value)
9
+ ActiveModel::Type::Boolean.new.cast(value) == true
10
+ end
11
+ end
12
+ end
13
+ 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
- parsed = JSON.parse(target.to_s)
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?(MISSING)
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"] && config["min"].to_i
7
- max = config["max"] && config["max"].to_i
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