completion-kit 0.28.23 → 0.28.32
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/stylesheets/completion_kit/application.css +73 -5
- data/app/controllers/completion_kit/api/v1/prompts_controller.rb +1 -0
- data/app/controllers/completion_kit/api/v1/responses_controller.rb +6 -3
- data/app/controllers/completion_kit/api/v1/runs_controller.rb +1 -1
- data/app/controllers/completion_kit/dashboard_controller.rb +12 -1
- data/app/controllers/completion_kit/prompts_controller.rb +1 -0
- data/app/controllers/completion_kit/runs_controller.rb +16 -9
- data/app/jobs/completion_kit/generate_row_job.rb +1 -1
- data/app/jobs/completion_kit/judge_review_job.rb +1 -2
- data/app/jobs/completion_kit/run_completion_check_job.rb +4 -0
- data/app/jobs/completion_kit/start_run_job.rb +29 -0
- data/app/models/completion_kit/metric.rb +1 -0
- data/app/models/completion_kit/prompt_serve.rb +41 -0
- data/app/models/completion_kit/run.rb +127 -16
- data/app/services/completion_kit/dashboard_stats.rb +54 -0
- data/app/services/completion_kit/judge_service.rb +4 -1
- data/app/services/completion_kit/mcp_tools/base.rb +12 -0
- data/app/services/completion_kit/mcp_tools/responses.rb +38 -3
- data/app/services/completion_kit/mcp_tools/runs.rb +33 -5
- data/app/services/completion_kit/metric_improvement_validator.rb +1 -2
- data/app/services/completion_kit/prompt_improvement_validator.rb +2 -3
- data/app/services/completion_kit/response_query.rb +94 -0
- data/app/views/completion_kit/api_reference/_body.html.erb +5 -3
- data/app/views/completion_kit/dashboard/_failing_checks_card.html.erb +36 -7
- data/app/views/completion_kit/dashboard/_failures_card.html.erb +6 -5
- data/app/views/completion_kit/dashboard/_worst_metric_card.html.erb +1 -1
- data/app/views/completion_kit/dashboard/show.html.erb +48 -9
- data/app/views/completion_kit/prompts/show.html.erb +13 -0
- data/app/views/completion_kit/responses/show.html.erb +1 -1
- data/app/views/completion_kit/runs/_form.html.erb +17 -0
- data/app/views/completion_kit/runs/_responses_region.html.erb +20 -0
- data/app/views/completion_kit/runs/_status_header.html.erb +22 -0
- data/app/views/completion_kit/runs/_status_panel.html.erb +18 -0
- data/app/views/completion_kit/runs/show.html.erb +30 -30
- data/db/migrate/20260728000001_add_max_tokens_to_completion_kit_runs.rb +5 -0
- data/db/migrate/20260730000001_add_judge_temperature_to_completion_kit_runs.rb +5 -0
- data/db/migrate/20260730000002_create_completion_kit_prompt_serves.rb +17 -0
- data/lib/completion_kit/version.rb +1 -1
- data/lib/completion_kit.rb +4 -1
- metadata +8 -1
|
@@ -16,6 +16,35 @@ module CompletionKit
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Prompts fetched most often by consumers in the window, with the daily
|
|
20
|
+
# counts behind each so the caller can draw a trend. Serving is independent
|
|
21
|
+
# of evaluating, so this deliberately does not go through display_scoped.
|
|
22
|
+
def self.top_served(since:, limit: 5)
|
|
23
|
+
rows = PromptServe.where("served_on >= ?", since.to_date)
|
|
24
|
+
.group(:prompt_id)
|
|
25
|
+
.pluck(Arel.sql("prompt_id"), Arel.sql("SUM(serve_count)"), Arel.sql("MAX(last_served_at)"))
|
|
26
|
+
return [] if rows.empty?
|
|
27
|
+
|
|
28
|
+
prompts = Prompt.where(id: rows.map(&:first)).index_by(&:id)
|
|
29
|
+
rows.filter_map do |prompt_id, total, last_at|
|
|
30
|
+
prompt = prompts[prompt_id]
|
|
31
|
+
next unless prompt
|
|
32
|
+
|
|
33
|
+
{ prompt: prompt, count: total.to_i, last_served_at: last_at }
|
|
34
|
+
end.sort_by { |row| -row[:count] }.first(limit)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One entry per day, zero-filled, matching the shape `activity` returns so
|
|
38
|
+
# the same sparkline markup renders it.
|
|
39
|
+
def self.serve_activity(days: 14)
|
|
40
|
+
since = (days - 1).days.ago.to_date
|
|
41
|
+
counts = PromptServe.where("served_on >= ?", since).group(:served_on).sum(:serve_count)
|
|
42
|
+
(0...days).map do |offset|
|
|
43
|
+
date = since + offset
|
|
44
|
+
{ date: date, count: counts[date] || counts[date.to_s] || 0 }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
19
48
|
# The metric with the lowest average judge score across succeeded reviews
|
|
20
49
|
# in the window — the prompt-engineering target. Dismissed metrics are
|
|
21
50
|
# skipped while their average holds at or above the score snapshotted when
|
|
@@ -70,6 +99,31 @@ module CompletionKit
|
|
|
70
99
|
(resolved.where(passed: true).count.to_f / total).round(2)
|
|
71
100
|
end
|
|
72
101
|
|
|
102
|
+
# Daily pass rate for deterministic checks across the trailing window,
|
|
103
|
+
# zero-filled and oldest first. `rate` is nil on days nothing resolved, so
|
|
104
|
+
# a quiet day stays distinguishable from a day everything failed.
|
|
105
|
+
def self.check_activity(days: 14)
|
|
106
|
+
since = (days - 1).days.ago.to_date
|
|
107
|
+
counts = Review.joins(:response)
|
|
108
|
+
.where.not(passed: nil)
|
|
109
|
+
.where("completion_kit_reviews.created_at >= ?", since.beginning_of_day)
|
|
110
|
+
.where(completion_kit_responses: { run_id: Run.visible_run_ids })
|
|
111
|
+
.group(Arel.sql("DATE(completion_kit_reviews.created_at)"), :passed)
|
|
112
|
+
.count
|
|
113
|
+
by_day = counts.each_with_object({}) do |((day, passed), total), acc|
|
|
114
|
+
bucket = acc[day.to_s] ||= { passed: 0, resolved: 0 }
|
|
115
|
+
bucket[:passed] += total if passed
|
|
116
|
+
bucket[:resolved] += total
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
(0...days).map do |offset|
|
|
120
|
+
date = since + offset
|
|
121
|
+
bucket = by_day[date.to_s] || { passed: 0, resolved: 0 }
|
|
122
|
+
rate = bucket[:resolved].zero? ? nil : (bucket[:passed].to_f / bucket[:resolved]).round(2)
|
|
123
|
+
{ date: date, resolved: bucket[:resolved], passed: bucket[:passed], rate: rate }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
73
127
|
def self.failing_checks(since:)
|
|
74
128
|
reviews = Review.where(passed: false)
|
|
75
129
|
.where("completion_kit_reviews.created_at >= ?", since)
|
|
@@ -4,9 +4,12 @@ module CompletionKit
|
|
|
4
4
|
class JudgeParseError < StandardError; end
|
|
5
5
|
|
|
6
6
|
class JudgeService
|
|
7
|
+
DEFAULT_TEMPERATURE = 0.0
|
|
8
|
+
|
|
7
9
|
def initialize(config = {})
|
|
8
10
|
@config = config
|
|
9
11
|
@judge_model = config[:judge_model].presence || ApiConfig.default_judge_model
|
|
12
|
+
@judge_temperature = config[:judge_temperature] || DEFAULT_TEMPERATURE
|
|
10
13
|
@judge_client = LlmClient.for_model(@judge_model, ApiConfig.for_model(@judge_model))
|
|
11
14
|
end
|
|
12
15
|
|
|
@@ -19,7 +22,7 @@ module CompletionKit
|
|
|
19
22
|
input_data: input_data,
|
|
20
23
|
human_examples: human_examples)
|
|
21
24
|
|
|
22
|
-
response = @judge_client.generate_completion(judge_prompt, model: @judge_model)
|
|
25
|
+
response = @judge_client.generate_completion(judge_prompt, model: @judge_model, temperature: @judge_temperature)
|
|
23
26
|
raise CompletionKit::ProviderError.from_client_error(response) if response.start_with?("Error:")
|
|
24
27
|
parse_judge_response(response)
|
|
25
28
|
end
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
module CompletionKit
|
|
2
2
|
module McpTools
|
|
3
3
|
module Base
|
|
4
|
+
DEFAULT_PAGE_LIMIT = 50
|
|
5
|
+
MAX_PAGE_LIMIT = 500
|
|
6
|
+
|
|
7
|
+
def page_bounds(args)
|
|
8
|
+
limit = args["limit"].to_i
|
|
9
|
+
limit = DEFAULT_PAGE_LIMIT if limit <= 0
|
|
10
|
+
limit = MAX_PAGE_LIMIT if limit > MAX_PAGE_LIMIT
|
|
11
|
+
offset = args["offset"].to_i
|
|
12
|
+
offset = 0 if offset < 0
|
|
13
|
+
[limit, offset]
|
|
14
|
+
end
|
|
15
|
+
|
|
4
16
|
def definitions
|
|
5
17
|
self::TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
|
|
6
18
|
end
|
|
@@ -3,10 +3,32 @@ module CompletionKit
|
|
|
3
3
|
module Responses
|
|
4
4
|
extend Base
|
|
5
5
|
|
|
6
|
+
FIELDS_DESCRIPTION = "Only return these keys, keeping the payload small. Response keys: id, run_id, " \
|
|
7
|
+
"input_data, response_text, expected_output, created_at, score, reviewed, reviews, " \
|
|
8
|
+
"status, attempts, row_index, error. Prefix with \"reviews.\" to trim each review, " \
|
|
9
|
+
"e.g. [\"score\", \"reviews.metric_name\", \"reviews.ai_score\"]. id is always included.".freeze
|
|
10
|
+
|
|
6
11
|
TOOLS = {
|
|
7
12
|
"responses_list" => {
|
|
8
|
-
description: "List responses for a run"
|
|
9
|
-
|
|
13
|
+
description: "List responses for a run, in row order. Returns " \
|
|
14
|
+
"{total, limit, offset, returned, responses}. Defaults to #{Base::DEFAULT_PAGE_LIMIT} rows " \
|
|
15
|
+
"because full payloads are large: use \"fields\" to drop the bodies, \"min_score\"/\"max_score\" " \
|
|
16
|
+
"to isolate low scorers, and sort \"score_asc\" to read the worst rows first. For per-metric " \
|
|
17
|
+
"averages of the whole run use runs_get instead of aggregating here.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
run_id: {type: "integer"},
|
|
22
|
+
limit: {type: "integer", description: "Rows to return; defaults to #{Base::DEFAULT_PAGE_LIMIT}, capped at #{Base::MAX_PAGE_LIMIT}."},
|
|
23
|
+
offset: {type: "integer", description: "Rows to skip before returning results."},
|
|
24
|
+
status: {type: "string", description: "Filter by row status: pending, retrying, succeeded or failed."},
|
|
25
|
+
min_score: {type: "number", description: "Only rows whose average judge score is at least this."},
|
|
26
|
+
max_score: {type: "number", description: "Only rows whose average judge score is at most this. Use with sort \"score_asc\" for failure-mode analysis."},
|
|
27
|
+
sort: {type: "string", enum: ResponseQuery::SORTS, description: "Row order; defaults to \"id\"."},
|
|
28
|
+
fields: {type: "array", items: {type: "string"}, description: FIELDS_DESCRIPTION}
|
|
29
|
+
},
|
|
30
|
+
required: ["run_id"]
|
|
31
|
+
},
|
|
10
32
|
handler: :list
|
|
11
33
|
},
|
|
12
34
|
"responses_get" => {
|
|
@@ -22,7 +44,20 @@ module CompletionKit
|
|
|
22
44
|
|
|
23
45
|
def self.list(args)
|
|
24
46
|
run = Run.find(args["run_id"])
|
|
25
|
-
|
|
47
|
+
query = ResponseQuery.new(
|
|
48
|
+
run,
|
|
49
|
+
status: args["status"], min_score: args["min_score"], max_score: args["max_score"],
|
|
50
|
+
sort: args["sort"], fields: args["fields"]
|
|
51
|
+
)
|
|
52
|
+
scope = query.relation
|
|
53
|
+
total = scope.count
|
|
54
|
+
limit, offset = page_bounds(args)
|
|
55
|
+
rows = scope.limit(limit).offset(offset).to_a
|
|
56
|
+
|
|
57
|
+
text_result({
|
|
58
|
+
total: total, limit: limit, offset: offset, returned: rows.length,
|
|
59
|
+
responses: rows.map { |response| query.serialize(response) }
|
|
60
|
+
})
|
|
26
61
|
end
|
|
27
62
|
|
|
28
63
|
def self.get(args)
|
|
@@ -3,6 +3,19 @@ module CompletionKit
|
|
|
3
3
|
module Runs
|
|
4
4
|
extend Base
|
|
5
5
|
|
|
6
|
+
TEMPERATURE_DESCRIPTION = "Sampling temperature for generation, 0 to 1. Defaults to the column default. " \
|
|
7
|
+
"Reasoning models ignore it and the run is flagged temperature_ignored.".freeze
|
|
8
|
+
|
|
9
|
+
MAX_TOKENS_DESCRIPTION = "Cap on generated tokens per row. Leave unset to use the provider client's default, " \
|
|
10
|
+
"which is what silently truncates long outputs and makes the judge score malformed " \
|
|
11
|
+
"JSON. Set it to whatever the prompt uses in production so the eval matches.".freeze
|
|
12
|
+
|
|
13
|
+
JUDGE_TEMPERATURE_DESCRIPTION = "Sampling temperature for the judge, 0 to 1. Defaults to 0 so re-judging the " \
|
|
14
|
+
"same output gives the same score. Raise it only to measure judge variance " \
|
|
15
|
+
"on purpose; any value above 0 makes the run's scores irreproducible.".freeze
|
|
16
|
+
|
|
17
|
+
GENERATION_FIELDS = %w[temperature max_tokens judge_temperature].freeze
|
|
18
|
+
|
|
6
19
|
TOOLS = {
|
|
7
20
|
"runs_list" => {
|
|
8
21
|
description: "List all runs",
|
|
@@ -10,7 +23,9 @@ module CompletionKit
|
|
|
10
23
|
handler: :list
|
|
11
24
|
},
|
|
12
25
|
"runs_get" => {
|
|
13
|
-
description: "Get a run by ID"
|
|
26
|
+
description: "Get a run by ID, including \"metric_averages\": a per-metric breakdown with each metric's " \
|
|
27
|
+
"average score (or pass rate for checks), how many rows it graded, and how many scored low. " \
|
|
28
|
+
"Use this to find the metric dragging a prompt down without listing responses.",
|
|
14
29
|
inputSchema: {type: "object", properties: {id: {type: "integer"}}, required: ["id"]},
|
|
15
30
|
handler: :get
|
|
16
31
|
},
|
|
@@ -21,6 +36,9 @@ module CompletionKit
|
|
|
21
36
|
properties: {
|
|
22
37
|
name: {type: "string"}, prompt_id: {type: "integer"},
|
|
23
38
|
dataset_id: {type: "integer"}, judge_model: {type: "string"},
|
|
39
|
+
temperature: {type: "number", description: TEMPERATURE_DESCRIPTION},
|
|
40
|
+
max_tokens: {type: "integer", description: MAX_TOKENS_DESCRIPTION},
|
|
41
|
+
judge_temperature: {type: "number", description: JUDGE_TEMPERATURE_DESCRIPTION},
|
|
24
42
|
output_column: {type: "string", description: "Dataset column to grade when prompt_id is omitted; defaults to \"actual_output\"."},
|
|
25
43
|
expected_column: {type: "string", description: "Dataset column holding each row's answer key / ground truth, graded by checks with compare_to \"expected\" and passed to the judge; defaults to \"expected_output\"."},
|
|
26
44
|
metric_ids: {type: "array", items: {type: "integer"}},
|
|
@@ -38,6 +56,9 @@ module CompletionKit
|
|
|
38
56
|
properties: {
|
|
39
57
|
id: {type: "integer"}, name: {type: "string"},
|
|
40
58
|
dataset_id: {type: "integer"}, judge_model: {type: "string"},
|
|
59
|
+
temperature: {type: "number", description: TEMPERATURE_DESCRIPTION},
|
|
60
|
+
max_tokens: {type: "integer", description: MAX_TOKENS_DESCRIPTION},
|
|
61
|
+
judge_temperature: {type: "number", description: JUDGE_TEMPERATURE_DESCRIPTION},
|
|
41
62
|
output_column: {type: "string"},
|
|
42
63
|
expected_column: {type: "string"},
|
|
43
64
|
metric_ids: {type: "array", items: {type: "integer"}},
|
|
@@ -84,7 +105,7 @@ module CompletionKit
|
|
|
84
105
|
end
|
|
85
106
|
|
|
86
107
|
def self.create(args)
|
|
87
|
-
run = Run.new(args.slice("name", "prompt_id", "dataset_id", "judge_model", "output_column", "expected_column"))
|
|
108
|
+
run = Run.new(args.slice("name", "prompt_id", "dataset_id", "judge_model", "output_column", "expected_column", *GENERATION_FIELDS))
|
|
88
109
|
if run.save
|
|
89
110
|
run.replace_metrics!(resolve_metric_ids(args))
|
|
90
111
|
run.update!(tag_names: args["tag_names"]) if args.key?("tag_names")
|
|
@@ -96,7 +117,7 @@ module CompletionKit
|
|
|
96
117
|
|
|
97
118
|
def self.update(args)
|
|
98
119
|
run = Run.find(args["id"])
|
|
99
|
-
if run.update(args.except("id", "metric_ids", "metric_group_id", "tag_names").slice("name", "dataset_id", "judge_model", "output_column", "expected_column"))
|
|
120
|
+
if run.update(args.except("id", "metric_ids", "metric_group_id", "tag_names").slice("name", "dataset_id", "judge_model", "output_column", "expected_column", *GENERATION_FIELDS))
|
|
100
121
|
run.replace_metrics!(resolve_metric_ids(args)) if args.key?("metric_ids") || args["metric_group_id"].present?
|
|
101
122
|
run.update!(tag_names: args["tag_names"]) if args.key?("tag_names")
|
|
102
123
|
text_result(run_payload(run.reload))
|
|
@@ -156,9 +177,16 @@ module CompletionKit
|
|
|
156
177
|
|
|
157
178
|
def self.run_payload(run)
|
|
158
179
|
json = run.as_json
|
|
159
|
-
|
|
180
|
+
warnings = []
|
|
181
|
+
if run.metric_ids.empty?
|
|
182
|
+
warnings << "No metrics are attached, so this run judges nothing. Attach metric_ids or a metric_group_id before generating."
|
|
183
|
+
end
|
|
184
|
+
if run.nondeterministic_judge?
|
|
185
|
+
warnings << "Judge temperature is #{run.judge_temperature}. Judging above 0 makes scores irreproducible: the same output can get a different score on a re-judge. Set judge_temperature to 0 unless you are deliberately measuring judge variance."
|
|
186
|
+
end
|
|
187
|
+
return json if warnings.empty?
|
|
160
188
|
|
|
161
|
-
json.merge("warning" => "
|
|
189
|
+
json.merge("warning" => warnings.join(" "))
|
|
162
190
|
end
|
|
163
191
|
end
|
|
164
192
|
end
|
|
@@ -87,9 +87,8 @@ module CompletionKit
|
|
|
87
87
|
|
|
88
88
|
def rescore(response, candidate)
|
|
89
89
|
run = response.run
|
|
90
|
-
config = ApiConfig.for_model(run.judge_model).merge(judge_model: run.judge_model)
|
|
91
90
|
rubric_text = Metric.rubric_text_for(Metric.normalize_rubric_bands(candidate.rubric_bands))
|
|
92
|
-
result = JudgeService.new(
|
|
91
|
+
result = JudgeService.new(run.judge_config).evaluate(
|
|
93
92
|
response.response_text,
|
|
94
93
|
response.expected_output,
|
|
95
94
|
run.prompt&.template,
|
|
@@ -77,15 +77,14 @@ module CompletionKit
|
|
|
77
77
|
client = LlmClient.for_model(model, ApiConfig.for_model(model))
|
|
78
78
|
raise CompletionKit::ConfigurationError, client.configuration_errors.join(", ") unless client.configured?
|
|
79
79
|
|
|
80
|
-
text = client.generate_completion(rendered,
|
|
80
|
+
text = client.generate_completion(rendered, **@run.generation_options(@run.prompt))
|
|
81
81
|
raise StandardError, text if text.to_s.start_with?("Error:")
|
|
82
82
|
|
|
83
83
|
text
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def judge_score(response, new_text)
|
|
87
|
-
|
|
88
|
-
judge = JudgeService.new(config)
|
|
87
|
+
judge = JudgeService.new(@run.judge_config)
|
|
89
88
|
scores = @run.metrics.select(&:llm_judge?).filter_map do |metric|
|
|
90
89
|
judge.evaluate(
|
|
91
90
|
new_text, response.expected_output, @candidate,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module CompletionKit
|
|
2
|
+
# Filtering, score-ordering and field projection for a run's responses,
|
|
3
|
+
# shared by the REST endpoint and the MCP tool so both surfaces answer
|
|
4
|
+
# "show me the worst rows, without the full bodies" the same way.
|
|
5
|
+
class ResponseQuery
|
|
6
|
+
SORTS = %w[id score_asc score_desc].freeze
|
|
7
|
+
REVIEW_FIELD_PREFIX = "reviews.".freeze
|
|
8
|
+
|
|
9
|
+
def initialize(run, status: nil, min_score: nil, max_score: nil, sort: nil, fields: nil)
|
|
10
|
+
@run = run
|
|
11
|
+
@status = status.presence
|
|
12
|
+
@min_score = min_score.presence&.to_f
|
|
13
|
+
@max_score = max_score.presence&.to_f
|
|
14
|
+
@sort = SORTS.include?(sort.to_s) ? sort.to_s : "id"
|
|
15
|
+
@fields = parse_fields(fields)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def relation
|
|
19
|
+
return base.order(:id) unless score_scoped?
|
|
20
|
+
|
|
21
|
+
ids = ordered_ids
|
|
22
|
+
base.where(id: ids).in_order_of(:id, ids)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def serialize(response)
|
|
26
|
+
project(response.as_json)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def base
|
|
32
|
+
scope = @run.responses.includes(:reviews)
|
|
33
|
+
@status ? scope.where(status: @status) : scope
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def score_scoped?
|
|
37
|
+
filtering? || @sort != "id"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def filtering?
|
|
41
|
+
!@min_score.nil? || !@max_score.nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ordered_ids
|
|
45
|
+
averages = score_averages
|
|
46
|
+
scored, unscored = base.order(:id).pluck(:id).partition { |id| averages.key?(id) }
|
|
47
|
+
kept = sort_by_score(scored.select { |id| in_range?(averages[id]) }, averages)
|
|
48
|
+
filtering? ? kept : kept + unscored
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Mirrors Response#score (mean of the row's judge scores, rounded to 2) so a
|
|
52
|
+
# min_score filter keeps exactly the rows whose reported score qualifies.
|
|
53
|
+
def score_averages
|
|
54
|
+
Review.where(response_id: base.select(:id))
|
|
55
|
+
.where.not(ai_score: nil)
|
|
56
|
+
.group(:response_id)
|
|
57
|
+
.pluck(:response_id, Arel.sql("AVG(ai_score)"))
|
|
58
|
+
.to_h { |id, avg| [id, avg.to_f.round(2)] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def in_range?(average)
|
|
62
|
+
return false if @min_score && average < @min_score
|
|
63
|
+
return false if @max_score && average > @max_score
|
|
64
|
+
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sort_by_score(ids, averages)
|
|
69
|
+
return ids if @sort == "id"
|
|
70
|
+
|
|
71
|
+
sorted = ids.sort_by { |id| averages[id] }
|
|
72
|
+
@sort == "score_desc" ? sorted.reverse : sorted
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_fields(raw)
|
|
76
|
+
names = Array(raw).flat_map { |value| value.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
77
|
+
{
|
|
78
|
+
top: names.reject { |name| name.start_with?(REVIEW_FIELD_PREFIX) }.map(&:to_sym),
|
|
79
|
+
reviews: names.select { |name| name.start_with?(REVIEW_FIELD_PREFIX) }
|
|
80
|
+
.map { |name| name.delete_prefix(REVIEW_FIELD_PREFIX).to_sym }
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def project(json)
|
|
85
|
+
return json if @fields[:top].empty? && @fields[:reviews].empty?
|
|
86
|
+
|
|
87
|
+
projected = json.slice(:id, *@fields[:top])
|
|
88
|
+
if @fields[:reviews].any?
|
|
89
|
+
projected[:reviews] = json[:reviews].map { |review| review.slice(*@fields[:reviews]) }
|
|
90
|
+
end
|
|
91
|
+
projected
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -129,12 +129,13 @@
|
|
|
129
129
|
<div class="ck-api-endpoint">
|
|
130
130
|
<p class="ck-api-method"><span class="ck-chip ck-chip--soft">POST</span> /api/v1/runs</p>
|
|
131
131
|
<p class="ck-meta-copy">Create a new run.</p>
|
|
132
|
-
<p class="ck-api-params"><strong>Optional:</strong> <code>name</code>, <code>prompt_id</code>, <code>dataset_id</code>, <code>metric_ids</code>, <code>judge_model</code>, <code>temperature</code>, <code>output_column</code> (score existing outputs: omit <code>prompt_id</code> and grade a dataset column instead, default <code>actual_output</code>), <code>expected_column</code> (dataset column holding each row's answer key, given to the judge and to checks that compare against the row's expected value, default <code>expected_output</code>)</p>
|
|
132
|
+
<p class="ck-api-params"><strong>Optional:</strong> <code>name</code>, <code>prompt_id</code>, <code>dataset_id</code>, <code>metric_ids</code>, <code>judge_model</code>, <code>temperature</code>, <code>judge_temperature</code> (defaults to 0 so re-judging is reproducible; above 0 the same output can score differently), <code>max_tokens</code> (cap on generated tokens per row; leave unset for the provider default, set it to match production when your prompt's output is long enough to truncate), <code>output_column</code> (score existing outputs: omit <code>prompt_id</code> and grade a dataset column instead, default <code>actual_output</code>), <code>expected_column</code> (dataset column holding each row's answer key, given to the judge and to checks that compare against the row's expected value, default <code>expected_output</code>)</p>
|
|
133
133
|
<%= render "completion_kit/api_reference/example", base_url: base_url, token: token, real_token: real_token, cmd: "curl -X POST #{base_url}/api/v1/runs \\\n -H \"Authorization: Bearer #{token}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"prompt_id\": 1, \"dataset_id\": 1, \"metric_ids\": [1, 2]}'" %>
|
|
134
134
|
</div>
|
|
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
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}\"" %>
|
|
139
140
|
</div>
|
|
140
141
|
<%= render "completion_kit/api_reference/resource_list", title: "Your recent runs",
|
|
@@ -185,8 +186,9 @@
|
|
|
185
186
|
<div class="ck-api-endpoint">
|
|
186
187
|
<p class="ck-api-method"><span class="ck-chip ck-chip--soft">GET</span> /api/v1/runs/:run_id/responses</p>
|
|
187
188
|
<p class="ck-meta-copy">List responses for a run, including nested review scores.</p>
|
|
188
|
-
<p class="ck-api-params"><strong>Optional filters:</strong> <code>status</code> (<code>pending</code>, <code>retrying</code>, <code>succeeded</code>, <code>failed</code>), plus <code>limit</code> and <code>offset</code></p>
|
|
189
|
-
|
|
189
|
+
<p class="ck-api-params"><strong>Optional filters:</strong> <code>status</code> (<code>pending</code>, <code>retrying</code>, <code>succeeded</code>, <code>failed</code>), <code>min_score</code>, <code>max_score</code>, <code>sort</code> (<code>id</code>, <code>score_asc</code>, <code>score_desc</code>), plus <code>limit</code> and <code>offset</code></p>
|
|
190
|
+
<p class="ck-api-params"><strong>Trim the payload:</strong> <code>fields</code> takes a comma-separated list of response keys and always returns <code>id</code>. Prefix with <code>reviews.</code> to trim each nested review, e.g. <code>fields=score,reviews.metric_name,reviews.ai_score</code></p>
|
|
191
|
+
<%= render "completion_kit/api_reference/example", base_url: base_url, token: token, real_token: real_token, cmd: "curl \"#{base_url}/api/v1/runs/1/responses?sort=score_asc&limit=10&fields=id,score,reviews.metric_name,reviews.ai_score\" \\\n -H \"Authorization: Bearer #{token}\"" %>
|
|
190
192
|
</div>
|
|
191
193
|
<div class="ck-api-endpoint">
|
|
192
194
|
<p class="ck-api-method"><span class="ck-chip ck-chip--soft">GET</span> /api/v1/runs/:run_id/responses/:id</p>
|
|
@@ -1,19 +1,48 @@
|
|
|
1
|
+
<% resolved = check_activity.sum { |day| day[:resolved] } %>
|
|
2
|
+
<% passed = check_activity.sum { |day| day[:passed] } %>
|
|
1
3
|
<div class="ck-card ck-stat-card ck-rise" id="ck-failing-checks-card" style="--rise-delay: 200ms;">
|
|
2
|
-
<p class="ck-kicker">
|
|
4
|
+
<p class="ck-kicker">Checks · 14D</p>
|
|
3
5
|
<div class="ck-stat-card__body">
|
|
4
|
-
|
|
6
|
+
<% if resolved.positive? %>
|
|
7
|
+
<% rate = passed.to_f / resolved %>
|
|
8
|
+
<span class="ck-stat-card__count is-<%= ck_pass_rate_kind(rate) %>"><%= (rate * 100).round %>%</span>
|
|
9
|
+
<span class="ck-stat-card__unit">passing</span>
|
|
10
|
+
<% else %>
|
|
11
|
+
<span class="ck-stat-card__metric ck-stat-card__metric--empty">Not run yet</span>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div class="ck-sparkline ck-sparkline--compact" role="img"
|
|
16
|
+
aria-label="Daily check pass rate over the last 14 days">
|
|
17
|
+
<% check_activity.each do |day| %>
|
|
18
|
+
<% kind = day[:rate] && ck_pass_rate_kind(day[:rate]) %>
|
|
19
|
+
<span class="ck-sparkline__bar<%= " is-#{kind}" if kind && kind != :high %>"
|
|
20
|
+
style="height: <%= day[:rate] ? [(day[:rate] * 100).round, 6].max : 0 %>%;"
|
|
21
|
+
title="<%= day[:date].strftime('%b %-d') %>: <%= day[:rate] ? "#{(day[:rate] * 100).round}% of #{day[:resolved]}" : 'no checks' %>"></span>
|
|
22
|
+
<% end %>
|
|
5
23
|
</div>
|
|
6
24
|
|
|
7
25
|
<% if failing_checks[:items].any? %>
|
|
8
26
|
<ul class="ck-failure-list">
|
|
9
|
-
<% failing_checks[:items].first(
|
|
27
|
+
<% failing_checks[:items].first(2).each do |item| %>
|
|
10
28
|
<li class="ck-failure-list__item">
|
|
11
|
-
<span class="ck-failure-list__surface ck-failure-list__surface--check">
|
|
12
|
-
<%= link_to item[:metric_name], completion_kit.run_path(item[:run]),
|
|
29
|
+
<span class="ck-failure-list__surface ck-failure-list__surface--check">fail</span>
|
|
30
|
+
<%= link_to item[:metric_name], completion_kit.run_path(item[:run]),
|
|
31
|
+
class: "ck-link ck-failure-list__cause", title: "Failed in #{item[:run].name}" %>
|
|
13
32
|
</li>
|
|
14
33
|
<% end %>
|
|
15
34
|
</ul>
|
|
16
|
-
<% else %>
|
|
17
|
-
<div class="ck-stat-card__foot"><span>No failing checks this week.</span></div>
|
|
18
35
|
<% end %>
|
|
36
|
+
|
|
37
|
+
<div class="ck-stat-card__foot">
|
|
38
|
+
<% if failing_checks[:count] > 2 %>
|
|
39
|
+
<span><%= failing_checks[:count] - 2 %> more failing.</span>
|
|
40
|
+
<% elsif failing_checks[:count].positive? %>
|
|
41
|
+
<span><%= pluralize(failing_checks[:count], "check") %> failing in the window.</span>
|
|
42
|
+
<% elsif resolved.positive? %>
|
|
43
|
+
<span>Every check passed in the window.</span>
|
|
44
|
+
<% else %>
|
|
45
|
+
<span>Add a check metric to a run to populate this.</span>
|
|
46
|
+
<% end %>
|
|
47
|
+
</div>
|
|
19
48
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div class="ck-card ck-stat-card ck-rise" id="ck-failures-card" style="--rise-delay: 180ms;">
|
|
2
|
-
<p class="ck-kicker">Failures ·
|
|
2
|
+
<p class="ck-kicker">Failures · 7D</p>
|
|
3
3
|
<div class="ck-stat-card__body">
|
|
4
4
|
<span class="ck-stat-card__count<%= failures[:count].positive? ? ' is-danger' : ' is-clean' %>"><%= failures[:count] %></span>
|
|
5
5
|
</div>
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
<ul class="ck-failure-list">
|
|
9
9
|
<% failures[:items].each do |item| %>
|
|
10
10
|
<li class="ck-failure-list__item">
|
|
11
|
-
<span class="ck-failure-list__surface ck-failure-list__surface--<%= item[:surface] %>"><%= item[:surface] %></span>
|
|
11
|
+
<span class="ck-failure-list__surface ck-failure-list__surface--<%= item[:surface] %>"><%= item[:surface] == "generation" ? "gen" : item[:surface] %></span>
|
|
12
12
|
<% if item[:run] %>
|
|
13
|
-
<%= link_to item[:
|
|
13
|
+
<%= link_to item[:run].name, completion_kit.run_path(item[:run]),
|
|
14
|
+
class: "ck-link ck-failure-list__cause", title: item[:cause] %>
|
|
14
15
|
<% else %>
|
|
15
|
-
<span class="ck-failure-list__cause"
|
|
16
|
+
<span class="ck-failure-list__cause" title="<%= item[:cause] %>">Deleted run</span>
|
|
16
17
|
<% end %>
|
|
17
18
|
<%= button_to completion_kit.dashboard_dismissals_path,
|
|
18
19
|
params: { dashboard_dismissal: { dismissable_type: item[:record].class.name,
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
|
|
27
28
|
<% if failures[:items].empty? || ignored_failures.any? %>
|
|
28
29
|
<div class="ck-stat-card__foot ck-stat-card__foot--split">
|
|
29
|
-
<span><% if failures[:items].empty? %>All clear
|
|
30
|
+
<span><% if failures[:items].empty? %>All clear. Nothing failed this week.<% end %></span>
|
|
30
31
|
<% if ignored_failures.any? %>
|
|
31
32
|
<details class="ck-flyout">
|
|
32
33
|
<summary class="ck-flyout__toggle"><%= ignored_failures.size %> ignored</summary>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div class="ck-card ck-stat-card ck-rise" id="ck-worst-metric-card" style="--rise-delay: 120ms;">
|
|
2
|
-
<p class="ck-kicker">Worst metric ·
|
|
2
|
+
<p class="ck-kicker">Worst metric · 7D</p>
|
|
3
3
|
<div class="ck-stat-card__body">
|
|
4
4
|
<% if worst_metric %>
|
|
5
5
|
<span class="ck-stat-card__metric"><%= worst_metric[:name] %></span>
|
|
@@ -28,20 +28,30 @@
|
|
|
28
28
|
</nav>
|
|
29
29
|
|
|
30
30
|
<% if @activity %>
|
|
31
|
-
<div class="ck-grid ck-grid--cards ck-grid--cards-4 ck-pulse-grid">
|
|
31
|
+
<div class="ck-grid ck-grid--cards <%= @failing_checks ? "ck-grid--cards-4" : "ck-grid--cards-3" %> ck-pulse-grid">
|
|
32
32
|
<div class="ck-card ck-stat-card ck-rise" style="--rise-delay: 60ms;">
|
|
33
|
-
<p class="ck-kicker">Activity ·
|
|
33
|
+
<p class="ck-kicker">Activity · 14D</p>
|
|
34
|
+
<% activity_total = @activity.sum { |d| d[:count] } %>
|
|
34
35
|
<% activity_max = @activity.map { |d| d[:count] }.max %>
|
|
35
|
-
<div class="ck-
|
|
36
|
+
<div class="ck-stat-card__body">
|
|
37
|
+
<span class="ck-stat-card__count"><%= activity_total %></span>
|
|
38
|
+
<span class="ck-stat-card__unit"><%= "run".pluralize(activity_total) %></span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="ck-sparkline ck-sparkline--compact" role="img" aria-label="<%= activity_total %> runs over the last 14 days">
|
|
36
41
|
<% @activity.each do |day| %>
|
|
37
42
|
<span class="ck-sparkline__bar<%= ' is-peak' if activity_max.to_i.positive? && day[:count] == activity_max %>"
|
|
38
|
-
style="height: <%= activity_max.to_i.zero? ? 0 : (day[:count] * 100.0 / activity_max).round %>%"
|
|
43
|
+
style="height: <%= activity_max.to_i.zero? ? 0 : [(day[:count] * 100.0 / activity_max).round, 6].max %>%"
|
|
39
44
|
title="<%= day[:date].strftime('%b %-d') %>: <%= day[:count] %> run<%= 's' unless day[:count] == 1 %>"></span>
|
|
40
45
|
<% end %>
|
|
41
46
|
</div>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
<div class="ck-stat-card__foot">
|
|
48
|
+
<% if activity_total.positive? %>
|
|
49
|
+
<% peak = @activity.max_by { |d| d[:count] } %>
|
|
50
|
+
<span>Busiest day <%= peak[:date].strftime("%b %-d") %> · <%= peak[:count] %></span>
|
|
51
|
+
<% else %>
|
|
52
|
+
<span>No runs in the window.</span>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
45
55
|
</div>
|
|
46
56
|
|
|
47
57
|
<%= render "completion_kit/dashboard/worst_metric_card",
|
|
@@ -50,8 +60,10 @@
|
|
|
50
60
|
<%= render "completion_kit/dashboard/failures_card",
|
|
51
61
|
failures: @failures, ignored_failures: @ignored_failures %>
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
<% if @failing_checks %>
|
|
64
|
+
<%= render "completion_kit/dashboard/failing_checks_card",
|
|
65
|
+
failing_checks: @failing_checks, check_activity: @check_activity %>
|
|
66
|
+
<% end %>
|
|
55
67
|
</div>
|
|
56
68
|
|
|
57
69
|
<div class="ck-card ck-card--spaced ck-rise" style="--rise-delay: 240ms;">
|
|
@@ -80,6 +92,33 @@
|
|
|
80
92
|
</div>
|
|
81
93
|
<% end %>
|
|
82
94
|
|
|
95
|
+
<div class="ck-card ck-card--spaced ck-rise" id="top_served_card" style="--rise-delay: 280ms;">
|
|
96
|
+
<p class="ck-kicker">Prompts served · 7D</p>
|
|
97
|
+
<% if @top_served.any? %>
|
|
98
|
+
<% serve_max = @serve_activity.map { |d| d[:count] }.max %>
|
|
99
|
+
<div class="ck-sparkline" role="img" aria-label="<%= @serve_activity.sum { |d| d[:count] } %> prompt fetches over the last 14 days">
|
|
100
|
+
<% @serve_activity.each do |day| %>
|
|
101
|
+
<span class="ck-sparkline__bar<%= ' is-peak' if serve_max.positive? && day[:count] == serve_max %>"
|
|
102
|
+
style="height: <%= serve_max.positive? ? [(day[:count] * 100.0 / serve_max).round, 4].max : 4 %>%;"
|
|
103
|
+
title="<%= day[:date].strftime('%b %-d') %>: <%= day[:count] %>"></span>
|
|
104
|
+
<% end %>
|
|
105
|
+
</div>
|
|
106
|
+
<ul class="ck-improvements">
|
|
107
|
+
<% @top_served.each do |row| %>
|
|
108
|
+
<li class="ck-improvement">
|
|
109
|
+
<%= link_to row[:prompt].name, prompt_path(row[:prompt]), class: "ck-improvement__name ck-link" %>
|
|
110
|
+
<span class="ck-improvement__versions"><%= row[:prompt].version_label %></span>
|
|
111
|
+
<span class="ck-improvement__scores"><span class="ck-serve-figure"><%= number_with_delimiter(row[:count]) %></span> fetches</span>
|
|
112
|
+
</li>
|
|
113
|
+
<% end %>
|
|
114
|
+
</ul>
|
|
115
|
+
<% else %>
|
|
116
|
+
<p class="ck-improvements__empty">
|
|
117
|
+
No prompts fetched in the last 7 days. Once your application reads a published prompt from the API, the prompts it uses most show up here.
|
|
118
|
+
</p>
|
|
119
|
+
<% end %>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
83
122
|
<section class="ck-card--spaced">
|
|
84
123
|
<div class="ck-split">
|
|
85
124
|
<h2 class="ck-section-title">Recent runs</h2>
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
<code class="ck-endpoint__url" id="prompt_endpoint"><%= request.base_url %><%= api_v1_prompt_path(@prompt.slug) %></code>
|
|
19
19
|
<button type="button" class="ck-icon-btn" title="Copy endpoint" aria-label="Copy endpoint" onclick="navigator.clipboard.writeText(document.getElementById('prompt_endpoint').textContent)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" width="14" height="14" aria-hidden="true"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg></button>
|
|
20
20
|
</div>
|
|
21
|
+
<p class="ck-meta-copy" id="prompt_serves">
|
|
22
|
+
<% if @serve_summary[:total].positive? %>
|
|
23
|
+
Served <span class="ck-serve-figure"><%= number_with_delimiter(@serve_summary[:total]) %></span>
|
|
24
|
+
<%= "time".pluralize(@serve_summary[:total]) %><% if @serve_summary[7] < @serve_summary[:total] %>,
|
|
25
|
+
<%= number_with_delimiter(@serve_summary[7]) %> in the last 7 days<% end %>.
|
|
26
|
+
<% if @serve_summary[:last_served_at] %>
|
|
27
|
+
Last fetched <time data-relative-time datetime="<%= @serve_summary[:last_served_at].utc.iso8601 %>"><%= time_ago_in_words(@serve_summary[:last_served_at]) %> ago</time>.
|
|
28
|
+
<% end %>
|
|
29
|
+
<% else %>
|
|
30
|
+
Not fetched yet. Counts appear here once something requests this prompt from the URL above.
|
|
31
|
+
<% end %>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
21
34
|
<% if @prompt.tags.any? %>
|
|
22
35
|
<div class="tag-marks-row">
|
|
23
36
|
<%= render "completion_kit/tags/marks", tags: @prompt.tags %>
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
<% if @reviews.any? %>
|
|
98
98
|
<section class="ck-card--spaced">
|
|
99
99
|
<div class="ck-prompt-preview__header">
|
|
100
|
-
<p class="ck-kicker">
|
|
100
|
+
<p class="ck-kicker">Judge's review</p>
|
|
101
101
|
<% if @run.judge_model.present? %>
|
|
102
102
|
<span class="ck-chip ck-chip--soft" style="text-transform: none;"><%= @run.judge_model %></span>
|
|
103
103
|
<% end %>
|