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
|
@@ -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
|
|
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: 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
|
|
7
|
-
options |= Regexp::MULTILINE if config
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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:
|
|
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: "
|
|
19
|
-
max: {type: "
|
|
20
|
-
|
|
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
|
-
"
|
|
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
|
|
@@ -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> <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> <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
|
|
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> <code>name</code> <strong>Optional:</strong> <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> <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> 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> 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> <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"
|
|
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"
|
|
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
|
|
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>
|