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
@@ -0,0 +1,96 @@
1
+ module CompletionKit
2
+ module Checks
3
+ class ListOverlap
4
+ MEASURES = %w[recall precision f1 jaccard].freeze
5
+ DEFAULT_MEASURE = "recall".freeze
6
+ DEFAULT_MIN = 1.0
7
+ SEPARATORS = /[,;\n]/
8
+ DECORATION = /\A(?:["'`]+|[-*•]\s+)|["'`]+\z/
9
+ PLAIN_NUMBER = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?\z/
10
+
11
+ def call(target, config)
12
+ actual = to_set(target, config)
13
+ expected = to_set(config["value"], config)
14
+ measure = MEASURES.include?(config["score_by"]) ? config["score_by"] : DEFAULT_MEASURE
15
+ overlap = (actual & expected).length
16
+ score = score_for(measure, overlap, actual.length, expected.length).round(4)
17
+ threshold = NumericValue.parse(config["min"]) || DEFAULT_MIN
18
+
19
+ Result.new(
20
+ passed: score >= threshold,
21
+ detail: "found #{overlap} of #{expected.length} expected, #{actual.length} returned (score #{score.round(2)})",
22
+ score: score
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def score_for(measure, overlap, actual_size, expected_size)
29
+ return 1.0 if actual_size.zero? && expected_size.zero?
30
+
31
+ precision = ratio(overlap, actual_size)
32
+ recall = expected_size.zero? ? 1.0 : ratio(overlap, expected_size)
33
+ case measure
34
+ when "precision" then precision
35
+ when "jaccard" then ratio(overlap, actual_size + expected_size - overlap)
36
+ when "f1" then f1(precision, recall)
37
+ else recall
38
+ end
39
+ end
40
+
41
+ def ratio(numerator, denominator)
42
+ return 0.0 if denominator.zero?
43
+
44
+ numerator.to_f / denominator
45
+ end
46
+
47
+ def f1(precision, recall)
48
+ return 0.0 if (precision + recall).zero?
49
+
50
+ (2 * precision * recall) / (precision + recall)
51
+ end
52
+
53
+ def to_set(value, config)
54
+ items = coerce_list(value).map { |item| normalize(item, config) }
55
+ items.reject(&:empty?).uniq
56
+ end
57
+
58
+ def normalize(item, config)
59
+ return number_text(item.to_s) if item.is_a?(::Numeric)
60
+
61
+ text = item.to_s.strip.gsub(DECORATION, "").strip
62
+ return number_text(text) if PLAIN_NUMBER.match?(text)
63
+
64
+ Flag.on?(config, "case_sensitive") ? text : text.downcase
65
+ end
66
+
67
+ def number_text(text)
68
+ number = BigDecimal(text)
69
+ number.frac.zero? ? number.to_i.to_s : number.to_s("F")
70
+ end
71
+
72
+ def coerce_list(value)
73
+ case value
74
+ when Array then value
75
+ when Hash, nil then []
76
+ else
77
+ text = value.to_s.strip
78
+ return [] if text.empty?
79
+
80
+ from_json(text) || text.split(SEPARATORS)
81
+ end
82
+ end
83
+
84
+ def from_json(text)
85
+ parsed = JSON.parse(text)
86
+ return parsed if parsed.is_a?(Array)
87
+ return parsed.split(SEPARATORS) if parsed.is_a?(String)
88
+ return [] if parsed.is_a?(Hash)
89
+
90
+ nil
91
+ rescue JSON::ParserError
92
+ nil
93
+ end
94
+ end
95
+ end
96
+ end
@@ -2,15 +2,9 @@ module CompletionKit
2
2
  module Checks
3
3
  class NotContains
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: false, detail: "contains #{value.inspect}")
15
9
  else
16
10
  Result.new(passed: true, detail: "does not contain #{value.inspect}")
@@ -0,0 +1,22 @@
1
+ module CompletionKit
2
+ module Checks
3
+ class NumericBounds
4
+ def call(target, config)
5
+ number = NumericValue.parse(target)
6
+ return Result.new(passed: false, detail: "#{NumericValue.preview(target)} is not a number") if number.nil?
7
+
8
+ min = NumericValue.parse(config["min"])
9
+ max = NumericValue.parse(config["max"])
10
+ shown = NumericValue.format(number)
11
+
12
+ if min && number < min
13
+ Result.new(passed: false, detail: "#{shown} is below min #{NumericValue.format(min)}")
14
+ elsif max && number > max
15
+ Result.new(passed: false, detail: "#{shown} is above max #{NumericValue.format(max)}")
16
+ else
17
+ Result.new(passed: true, detail: "#{shown} is within bounds")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ module CompletionKit
2
+ module Checks
3
+ class NumericEquals
4
+ PERCENT = /\A(.*)%\z/
5
+
6
+ def call(target, config)
7
+ actual = NumericValue.decimal(target)
8
+ return Result.new(passed: false, detail: "#{NumericValue.preview(target)} is not a number") if actual.nil?
9
+
10
+ expected = NumericValue.decimal(config["value"])
11
+ return Result.new(passed: false, detail: "expected #{NumericValue.preview(config["value"])} is not a number") if expected.nil?
12
+
13
+ difference = (actual - expected).abs
14
+ allowed = self.class.allowance(config["tolerance"], expected)
15
+ shown = "#{NumericValue.format(actual)} vs #{NumericValue.format(expected)}"
16
+
17
+ if difference <= allowed
18
+ Result.new(passed: true, detail: "#{shown}, within #{NumericValue.format(allowed)}")
19
+ else
20
+ Result.new(passed: false, detail: "#{shown}, off by #{NumericValue.format(difference)} (allowed #{NumericValue.format(allowed)})")
21
+ end
22
+ end
23
+
24
+ def self.allowance(raw, expected)
25
+ share = PERCENT.match(raw.to_s)
26
+ return (NumericValue.decimal(share[1]) || BigDecimal("0")) / 100 * expected.abs if share
27
+
28
+ NumericValue.decimal(raw) || BigDecimal("0")
29
+ end
30
+
31
+ def self.tolerance_value(raw)
32
+ share = PERCENT.match(raw.to_s)
33
+ NumericValue.parse(share ? share[1] : raw)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,45 @@
1
+ module CompletionKit
2
+ module Checks
3
+ module NumericValue
4
+ STRIPPED = /[\s_   ]/
5
+ CURRENCY = /\A([+-]?)[$£€]/
6
+ GROUPED = /\A[+-]?[1-9]\d{0,2}(?:,\d{3})+(?:\.\d*)?(?:[eE][+-]?\d+)?\z/
7
+ NUMERIC = /\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/
8
+ PREVIEW_LENGTH = 40
9
+
10
+ def self.parse(value)
11
+ return finite(value.to_f) if value.is_a?(::Numeric)
12
+
13
+ text = clean(value)
14
+ return nil unless NUMERIC.match?(text)
15
+
16
+ finite(Float(text))
17
+ end
18
+
19
+ def self.decimal(value)
20
+ return nil if parse(value).nil?
21
+
22
+ BigDecimal(value.is_a?(::Numeric) ? value.to_s : clean(value))
23
+ end
24
+
25
+ def self.clean(value)
26
+ text = value.to_s.gsub(STRIPPED, "").sub(CURRENCY) { ::Regexp.last_match(1) }
27
+ GROUPED.match?(text) ? text.delete(",") : text
28
+ end
29
+
30
+ def self.finite(number)
31
+ number.finite? ? number : nil
32
+ end
33
+
34
+ def self.format(number)
35
+ return number.to_i.to_s if number == number.to_i
36
+
37
+ number.to_s
38
+ end
39
+
40
+ def self.preview(value)
41
+ value.inspect.truncate(PREVIEW_LENGTH)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -3,8 +3,8 @@ module CompletionKit
3
3
  class Regex
4
4
  def call(target, config)
5
5
  options = 0
6
- options |= Regexp::IGNORECASE if config["case_sensitive"] == false
7
- options |= Regexp::MULTILINE if config["multiline"] == true
6
+ options |= Regexp::IGNORECASE unless Flag.on?(config, "case_sensitive")
7
+ options |= Regexp::MULTILINE if Flag.on?(config, "multiline")
8
8
  pattern = Regexp.new(config["pattern"].to_s, options)
9
9
 
10
10
  if pattern.match?(target.to_s)
@@ -8,7 +8,10 @@ module CompletionKit
8
8
  "regex" => Regex,
9
9
  "valid_json" => ValidJson,
10
10
  "json_path_equals" => JsonPathEquals,
11
- "length_bounds" => LengthBounds
11
+ "length_bounds" => LengthBounds,
12
+ "list_overlap" => ListOverlap,
13
+ "numeric_bounds" => NumericBounds,
14
+ "numeric_equals" => NumericEquals
12
15
  }.freeze
13
16
 
14
17
  REQUIRED_KEYS = {
@@ -18,10 +21,43 @@ module CompletionKit
18
21
  "regex" => %w[pattern],
19
22
  "valid_json" => [],
20
23
  "json_path_equals" => %w[json_path expected],
21
- "length_bounds" => []
24
+ "length_bounds" => [],
25
+ "list_overlap" => %w[value],
26
+ "numeric_bounds" => [],
27
+ "numeric_equals" => %w[value]
22
28
  }.freeze
23
29
 
24
- VALUE_COMPARISON_KINDS = %w[contains not_contains equals].freeze
30
+ CONFIG_KEYS = {
31
+ "contains" => %w[value case_sensitive trim],
32
+ "not_contains" => %w[value case_sensitive trim],
33
+ "equals" => %w[value case_sensitive trim],
34
+ "regex" => %w[pattern case_sensitive multiline],
35
+ "valid_json" => [],
36
+ "json_path_equals" => %w[json_path expected],
37
+ "length_bounds" => %w[min max],
38
+ "list_overlap" => %w[value score_by min case_sensitive],
39
+ "numeric_bounds" => %w[min max],
40
+ "numeric_equals" => %w[value tolerance]
41
+ }.freeze
42
+
43
+ SHARED_KEYS = %w[check_kind target target_path compare_to expected_path].freeze
44
+ FLAG_KEYS = %w[case_sensitive multiline trim].freeze
45
+ NUMBER_KEYS = %w[min max tolerance].freeze
46
+ DEFAULTS = { "target" => "response_text", "score_by" => ListOverlap::DEFAULT_MEASURE }.freeze
47
+
48
+ EXPECTED_KEYS = {
49
+ "contains" => "value",
50
+ "not_contains" => "value",
51
+ "equals" => "value",
52
+ "json_path_equals" => "expected",
53
+ "list_overlap" => "value",
54
+ "numeric_equals" => "value"
55
+ }.freeze
56
+
57
+ RAW_TARGET_KINDS = %w[list_overlap numeric_bounds numeric_equals].freeze
58
+ RAW_EXPECTED_KINDS = %w[json_path_equals list_overlap numeric_equals].freeze
59
+ BOUNDED_KINDS = %w[length_bounds numeric_bounds].freeze
60
+ SCORING_KINDS = %w[list_overlap].freeze
25
61
 
26
62
  KINDS = CHECKS.keys.freeze
27
63
 
@@ -33,13 +69,80 @@ module CompletionKit
33
69
  REQUIRED_KEYS
34
70
  end
35
71
 
72
+ def self.config_keys(kind)
73
+ CONFIG_KEYS.fetch(kind, [])
74
+ end
75
+
36
76
  def self.compares_value?(kind)
37
- VALUE_COMPARISON_KINDS.include?(kind)
77
+ EXPECTED_KEYS.key?(kind)
78
+ end
79
+
80
+ def self.expected_key(kind)
81
+ EXPECTED_KEYS[kind]
82
+ end
83
+
84
+ def self.comparable_kinds
85
+ EXPECTED_KEYS.keys
86
+ end
87
+
88
+ def self.raw_target?(kind)
89
+ RAW_TARGET_KINDS.include?(kind)
90
+ end
91
+
92
+ def self.raw_expected?(kind)
93
+ RAW_EXPECTED_KINDS.include?(kind)
94
+ end
95
+
96
+ def self.bounded?(kind)
97
+ BOUNDED_KINDS.include?(kind)
98
+ end
99
+
100
+ def self.scores?(kind)
101
+ SCORING_KINDS.include?(kind)
38
102
  end
39
103
 
40
104
  def self.fetch(kind)
41
105
  CHECKS.fetch(kind).new
42
106
  end
107
+
108
+ def self.unused_keys(config)
109
+ kind = config["check_kind"]
110
+ keys = config["compare_to"] == "expected" && compares_value?(kind) ? [expected_key(kind)] : %w[compare_to expected_path]
111
+ config["target"] == "json_path" ? keys : keys + %w[target_path]
112
+ end
113
+
114
+ def self.canonical(config)
115
+ hash = config.to_h.stringify_keys
116
+ relevant = hash.slice(*SHARED_KEYS, *config_keys(hash["check_kind"])).except(*unused_keys(hash))
117
+ relevant.to_h { |key, value| [key, canonical_value(key, value)] }.reject { |key, value| omitted?(key, value) }
118
+ end
119
+
120
+ def self.canonical_value(key, value)
121
+ case key
122
+ when *FLAG_KEYS then Flag.cast(value)
123
+ when *NUMBER_KEYS then NumericValue.parse(value) || value
124
+ when "value" then canonical_operand(value)
125
+ else value
126
+ end
127
+ end
128
+
129
+ def self.canonical_operand(value)
130
+ return value.to_s if value.is_a?(::Numeric)
131
+ return value unless value.is_a?(String)
132
+
133
+ parsed = parse_json(value)
134
+ parsed.is_a?(Array) ? parsed : value
135
+ end
136
+
137
+ def self.parse_json(text)
138
+ JSON.parse(text)
139
+ rescue JSON::ParserError
140
+ text
141
+ end
142
+
143
+ def self.omitted?(key, value)
144
+ value.nil? || value == "" || (FLAG_KEYS.include?(key) && !value) || DEFAULTS[key] == value
145
+ end
43
146
  end
44
147
  end
45
148
  end
@@ -1,5 +1,9 @@
1
1
  module CompletionKit
2
2
  module Checks
3
- Result = Data.define(:passed, :detail)
3
+ Result = Data.define(:passed, :detail, :score) do
4
+ def initialize(passed:, detail:, score: nil)
5
+ super
6
+ end
7
+ end
4
8
  end
5
9
  end
@@ -3,28 +3,63 @@ module CompletionKit
3
3
  module TargetResolver
4
4
  TARGETS = %w[response_text input_data json_path].freeze
5
5
  UNRESOLVED = Object.new.freeze
6
+ ARRAY_INDEX = /\A\d+\z/
7
+ ROOT = /\A\$(?:\.|\z)/
6
8
 
7
9
  def self.call(response, config)
10
+ stringify(call_value(response, config))
11
+ end
12
+
13
+ def self.call_value(response, config)
8
14
  case config["target"]
9
15
  when "input_data"
10
16
  response.input_data
11
17
  when "json_path"
12
- resolve_json_path(response.response_text, config["target_path"].to_s)
18
+ dig_json(response.response_text, config["target_path"])
13
19
  else
14
20
  response.response_text
15
21
  end
16
22
  end
17
23
 
18
- def self.resolve_json_path(text, path)
19
- parsed = JSON.parse(text.to_s)
20
- value = path.split(".").reduce(parsed) do |node, key|
21
- return UNRESOLVED unless node.is_a?(Hash) && node.key?(key)
24
+ def self.dig_json(text, path)
25
+ walk(JSON.parse(text.to_s), path)
26
+ rescue JSON::ParserError
27
+ UNRESOLVED
28
+ end
22
29
 
23
- node[key]
30
+ def self.walk(document, path)
31
+ normalize_path(path).split(".").reduce(document) do |node, key|
32
+ case node
33
+ when Hash
34
+ return UNRESOLVED unless node.key?(key)
35
+
36
+ node[key]
37
+ when Array
38
+ index = array_index(node, key)
39
+ return UNRESOLVED if index.nil?
40
+
41
+ node[index]
42
+ else
43
+ return UNRESOLVED
44
+ end
24
45
  end
46
+ end
47
+
48
+ def self.normalize_path(path)
49
+ path.to_s.strip.sub(ROOT, "")
50
+ end
51
+
52
+ def self.array_index(array, key)
53
+ return nil unless ARRAY_INDEX.match?(key)
54
+
55
+ index = key.to_i
56
+ index < array.length ? index : nil
57
+ end
58
+
59
+ def self.stringify(value)
60
+ return value if value.equal?(UNRESOLVED)
61
+
25
62
  value.to_s
26
- rescue JSON::ParserError
27
- UNRESOLVED
28
63
  end
29
64
  end
30
65
  end
@@ -0,0 +1,19 @@
1
+ module CompletionKit
2
+ module Checks
3
+ module Textual
4
+ def self.operands(target, config)
5
+ value = config["value"].to_s
6
+ haystack = target.to_s
7
+ return [value.strip, haystack.strip] if Flag.on?(config, "trim")
8
+
9
+ [value, haystack]
10
+ end
11
+
12
+ def self.include?(haystack, value, config)
13
+ return haystack.include?(value) if Flag.on?(config, "case_sensitive")
14
+
15
+ haystack.downcase.include?(value.downcase)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -3,30 +3,44 @@ module CompletionKit
3
3
  module Metrics
4
4
  extend Base
5
5
 
6
+ CASE_SENSITIVE_NOTE = "A check with no case_sensitive matches case-insensitively, regex and list_overlap included; " \
7
+ "send case_sensitive: true to match case exactly. Before 0.29.0 a regex check read a missing case_sensitive as case-sensitive."
8
+
6
9
  CHECK_CONFIG_SCHEMA = {
7
10
  type: "object",
8
11
  properties: {
9
12
  check_kind: {type: "string", enum: CompletionKit::Checks::Registry.kinds},
10
13
  target: {type: "string", enum: CompletionKit::Checks::TargetResolver::TARGETS},
11
14
  target_path: {type: "string"},
12
- value: {type: "string"},
15
+ value: {type: %w[string number array], items: {type: %w[string number]}, description: "Only list_overlap takes a list, as a JSON array or as comma-separated text; every other rule refuses one"},
13
16
  pattern: {type: "string"},
14
17
  json_path: {type: "string"},
15
18
  expected: {},
16
19
  compare_to: {type: "string", enum: %w[constant expected]},
17
20
  expected_path: {type: "string"},
18
- min: {type: "integer"},
19
- max: {type: "integer"},
20
- case_sensitive: {type: "boolean"},
21
+ min: {type: "number"},
22
+ max: {type: "number"},
23
+ score_by: {type: "string", enum: CompletionKit::Checks::ListOverlap::MEASURES},
24
+ tolerance: {type: %w[number string], description: "A plain number for a fixed amount, or a percentage string such as \"2%\" for a share of the expected value"},
25
+ case_sensitive: {type: "boolean", description: CASE_SENSITIVE_NOTE},
21
26
  multiline: {type: "boolean"},
22
27
  trim: {type: "boolean"}
23
28
  }
24
29
  }.freeze
25
30
 
26
31
  CHECK_CONFIG_HINT = "For a deterministic check set metric_type:\"check\" and check_config. Per-kind required keys: " \
27
- "value (contains/not_contains/equals), pattern (regex), json_path+expected (json_path_equals), " \
28
- "min and/or max (length_bounds); valid_json takes no extra keys. target_path is required when target is json_path. " \
29
- "For contains, not_contains, and equals, set compare_to:\"expected\" to grade against each row's own expected_output (ground truth) instead of a constant value (drop value); add expected_path to dig into the expected value when it is JSON."
32
+ "value (contains/not_contains/equals/list_overlap/numeric_equals), pattern (regex), json_path+expected (json_path_equals), " \
33
+ "min and/or max (length_bounds/numeric_bounds); valid_json takes no extra keys. target_path is required when target is json_path; " \
34
+ "a target_path sent with no target sets target to json_path, and one sent with any other target is refused. " \
35
+ "target_path walks array indices, as in items.0.name; any path may start at the root with $., as in $.items.0.name. " \
36
+ "list_overlap grades a list-valued field and stores a fraction rather than a verdict: score_by is recall, precision, f1 or jaccard (recall by default), " \
37
+ "and min is the fraction that counts as a pass (1, a perfect score, by default). " \
38
+ "numeric_bounds and numeric_equals read a number, ignoring the thousands commas and currency symbol a figure is written with; " \
39
+ "a comma counts only as a thousands separator, with three digits after it and no decimal point before it, so 1,5 is not a number; " \
40
+ "numeric_equals takes a tolerance, either a plain number for a fixed amount or a percentage string such as 2% for a share of the expected value. " \
41
+ "#{CASE_SENSITIVE_NOTE} " \
42
+ "Set compare_to:\"expected\" on #{CompletionKit::Checks::Registry.comparable_kinds.join(", ")} to grade against each row's own expected_output (ground truth) " \
43
+ "instead of a constant; drop value (or expected, for json_path_equals) and add expected_path to dig into the answer key when it is JSON."
30
44
 
31
45
  TOOLS = {
32
46
  "metrics_list" => {
@@ -149,17 +149,17 @@ module CompletionKit
149
149
  def check_mapping(type, text)
150
150
  case type
151
151
  when "contains"
152
- [{ "check_kind" => "contains", "target" => "response_text", "value" => text }, "Contains #{text.truncate(30).inspect}"]
152
+ [{ "check_kind" => "contains", "target" => "response_text", "value" => text, "case_sensitive" => true }, "Contains #{text.truncate(30).inspect}"]
153
153
  when "icontains"
154
154
  [{ "check_kind" => "contains", "target" => "response_text", "value" => text, "case_sensitive" => false }, "Contains #{text.truncate(30).inspect}"]
155
155
  when "not-contains"
156
- [{ "check_kind" => "not_contains", "target" => "response_text", "value" => text }, "Does not contain #{text.truncate(30).inspect}"]
156
+ [{ "check_kind" => "not_contains", "target" => "response_text", "value" => text, "case_sensitive" => true }, "Does not contain #{text.truncate(30).inspect}"]
157
157
  when "not-icontains"
158
158
  [{ "check_kind" => "not_contains", "target" => "response_text", "value" => text, "case_sensitive" => false }, "Does not contain #{text.truncate(30).inspect}"]
159
159
  when "equals"
160
- [{ "check_kind" => "equals", "target" => "response_text", "value" => text }, "Equals #{text.truncate(30).inspect}"]
160
+ [{ "check_kind" => "equals", "target" => "response_text", "value" => text, "case_sensitive" => true }, "Equals #{text.truncate(30).inspect}"]
161
161
  when "regex"
162
- [{ "check_kind" => "regex", "target" => "response_text", "pattern" => text }, "Matches /#{text.truncate(30)}/"]
162
+ [{ "check_kind" => "regex", "target" => "response_text", "pattern" => text, "case_sensitive" => true }, "Matches /#{text.truncate(30)}/"]
163
163
  when "is-json"
164
164
  [{ "check_kind" => "valid_json", "target" => "response_text" }, "Valid JSON"]
165
165
  end
@@ -9,5 +9,11 @@ module CompletionKit
9
9
 
10
10
  "same"
11
11
  end
12
+
13
+ def score_delta(left, right)
14
+ return nil if left.nil? || right.nil?
15
+
16
+ (right.to_f - left.to_f).round(2)
17
+ end
12
18
  end
13
19
  end
@@ -135,7 +135,7 @@
135
135
  <div class="ck-api-endpoint">
136
136
  <p class="ck-api-method"><span class="ck-chip ck-chip--soft">GET</span> /api/v1/runs/:id</p>
137
137
  <p class="ck-meta-copy">Get a run with status, progress, response count, and average score.</p>
138
- <p class="ck-api-params"><strong>Also returns:</strong>&ensp;<code>metric_averages</code>, a per-metric breakdown with each metric's average score (<code>pass_rate</code> for checks), the number of rows it graded, and <code>low_count</code>, how many scored below the medium quality threshold</p>
138
+ <p class="ck-api-params"><strong>Also returns:</strong>&ensp;<code>metric_averages</code>, a per-metric breakdown with each metric's average score (<code>pass_rate</code> for checks, plus <code>avg_check_score</code> for a <code>list_overlap</code> check, the average fraction from 0 to 1), the number of rows it graded, and <code>low_count</code>, how many scored below the medium quality threshold</p>
139
139
  <%= render "completion_kit/api_reference/example", base_url: base_url, token: token, real_token: real_token, cmd: "curl #{base_url}/api/v1/runs/1 \\\n -H \"Authorization: Bearer #{token}\"" %>
140
140
  </div>
141
141
  <%= render "completion_kit/api_reference/resource_list", title: "Your recent runs",
@@ -166,7 +166,7 @@
166
166
  </div>
167
167
  <div class="ck-api-endpoint">
168
168
  <p class="ck-api-method"><span class="ck-chip ck-chip--soft">GET</span> /api/v1/runs/:id/compare?with=:other_id</p>
169
- <p class="ck-meta-copy">Side-by-side comparison against another run. Returns <code>{left_run_id, right_run_id, metric_ids: [...], rows: [...]}</code> with one row per input case, per-metric scores on both sides, and the delta. Rows are keyed by the left run's input cases: a case present only in the left run is returned with the right side nulled out, and a case present only in the right run is omitted.</p>
169
+ <p class="ck-meta-copy">Side-by-side comparison against another run. Returns <code>{left_run_id, right_run_id, metric_ids: [...], rows: [...]}</code> with one row per input case, per-metric scores on both sides, and the delta. A check reports <code>left_passed</code>, <code>right_passed</code> and <code>result_change</code>, and a <code>list_overlap</code> check also reports <code>left_check_score</code>, <code>right_check_score</code> and <code>check_score_delta</code>, so a fraction that moved shows even when the verdict did not. Rows are keyed by the left run's input cases: a case present only in the left run is returned with the right side nulled out, and a case present only in the right run is omitted.</p>
170
170
  <%= render "completion_kit/api_reference/example", base_url: base_url, token: token, real_token: real_token, cmd: "curl \"#{base_url}/api/v1/runs/1/compare?with=2\" \\\n -H \"Authorization: Bearer #{token}\"" %>
171
171
  </div>
172
172
  <div class="ck-api-endpoint">
@@ -237,8 +237,12 @@
237
237
  </div>
238
238
  <div class="ck-api-endpoint">
239
239
  <p class="ck-api-method"><span class="ck-chip ck-chip--soft">POST</span> /api/v1/metrics</p>
240
- <p class="ck-meta-copy">Create a metric. Defaults to an LLM judge; pass <code>metric_type</code> <code>check</code> with a <code>check_config</code> for a deterministic pass/fail check.</p>
240
+ <p class="ck-meta-copy">Create a metric. Defaults to an LLM judge; pass <code>metric_type</code> <code>check</code> with a <code>check_config</code> for a deterministic check that runs with no model and no cost.</p>
241
241
  <p class="ck-api-params"><strong>Required:</strong>&ensp;<code>name</code>&emsp;<strong>Optional:</strong>&ensp;<code>metric_type</code> (llm_judge default, or check), <code>instruction</code> and <code>rubric_bands</code> (array of {stars, description}) for judges, <code>check_config</code> (<code>check_kind</code>, <code>target</code>, plus per-kind keys) for checks</p>
242
+ <p class="ck-api-params"><strong>Check kinds:</strong>&ensp;<code>contains</code>, <code>not_contains</code>, <code>equals</code> (<code>value</code>); <code>regex</code> (<code>pattern</code>); <code>valid_json</code>; <code>json_path_equals</code> (<code>json_path</code>, <code>expected</code>); <code>length_bounds</code> and <code>numeric_bounds</code> (<code>min</code> and/or <code>max</code>); <code>list_overlap</code> (<code>value</code>, <code>score_by</code>, <code>min</code>); <code>numeric_equals</code> (<code>value</code>, <code>tolerance</code>)</p>
243
+ <p class="ck-api-params"><strong>Case:</strong>&ensp;A check with no <code>case_sensitive</code> matches case-insensitively, <code>regex</code> and <code>list_overlap</code> included. Send <code>"case_sensitive": true</code> to match case exactly. Before 0.29.0 a <code>regex</code> check read a missing <code>case_sensitive</code> as case-sensitive.</p>
244
+ <p class="ck-api-params"><strong>Grading against the answer key:</strong>&ensp;set <code>compare_to</code> to <code>expected</code> on <code>contains</code>, <code>not_contains</code>, <code>equals</code>, <code>json_path_equals</code>, <code>list_overlap</code> or <code>numeric_equals</code> to grade each row against its own <code>expected_output</code> rather than a constant. Drop <code>value</code> (or <code>expected</code>, on <code>json_path_equals</code>) and add <code>expected_path</code> to name the field to read out of a JSON answer key. A <code>target_path</code>, an <code>expected_path</code> and a <code>json_path</code> all walk array indices, as in <code>items.0.name</code>, and may start at the root with <code>$.</code>, as in <code>$.items.0.name</code>. A <code>target_path</code> sent with no <code>target</code> sets <code>target</code> to <code>json_path</code>, and one sent with any other <code>target</code> is refused.</p>
245
+ <p class="ck-api-params"><strong>Scores, not just verdicts:</strong>&ensp;<code>list_overlap</code> grades a list-valued field and stores a fraction on the review as <code>check_score</code>, with <code>score_by</code> one of recall, precision, f1 or jaccard. Its <code>min</code> is the fraction that counts as a pass. <code>numeric_equals</code> takes a <code>tolerance</code> that is a plain number for a fixed amount, or a percentage string such as <code>"2%"</code> for a share of the expected value. Each run averages it per metric as <code>avg_check_score</code> inside <code>metric_averages</code>, so a partial answer has run history and can gate a regression.</p>
242
246
  <%= render "completion_kit/api_reference/example", base_url: base_url, token: token, real_token: real_token, cmd: "curl -X POST #{base_url}/api/v1/metrics \\\n -H \"Authorization: Bearer #{token}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"relevance\", \"instruction\": \"Is the response relevant?\"}'" %>
243
247
  </div>
244
248
  <div class="ck-api-endpoint">
@@ -1,23 +1,17 @@
1
1
  <% config = local_assigns.fetch(:config) %>
2
2
  <dl class="ck-check-spec">
3
3
  <div class="ck-check-spec__row">
4
- <dt class="ck-check-spec__term">Rule</dt>
4
+ <dt class="ck-check-spec__term"><%= ck_check_field_label("check_kind") %></dt>
5
5
  <dd class="ck-check-spec__val"><%= ck_check_kind_label(config["check_kind"]) %></dd>
6
6
  </div>
7
7
  <div class="ck-check-spec__row">
8
- <dt class="ck-check-spec__term">Checks</dt>
8
+ <dt class="ck-check-spec__term"><%= ck_check_field_label("target") %></dt>
9
9
  <dd class="ck-check-spec__val"><%= ck_check_target_label(config["target"]) %></dd>
10
10
  </div>
11
- <% config.except("check_kind", "target").each do |key, value| %>
11
+ <% ck_check_spec_fields(config).each do |key, value| %>
12
12
  <div class="ck-check-spec__row">
13
- <dt class="ck-check-spec__term"><%= ck_check_field_label(key) %></dt>
14
- <dd class="ck-check-spec__val">
15
- <% if key == "compare_to" %>
16
- <%= ck_check_field_value(key, value) %>
17
- <% else %>
18
- <code class="ck-check-spec__code"><%= value %></code>
19
- <% end %>
20
- </dd>
13
+ <dt class="ck-check-spec__term"><%= ck_check_field_label(key, config["check_kind"]) %></dt>
14
+ <dd class="ck-check-spec__val"><%= ck_check_spec_value(key, value) %></dd>
21
15
  </div>
22
16
  <% end %>
23
17
  </dl>