completion-kit 0.28.24 → 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/completion_kit/application.css +73 -5
  3. data/app/controllers/completion_kit/api/v1/prompts_controller.rb +1 -0
  4. data/app/controllers/completion_kit/api/v1/responses_controller.rb +6 -3
  5. data/app/controllers/completion_kit/api/v1/runs_controller.rb +1 -1
  6. data/app/controllers/completion_kit/dashboard_controller.rb +12 -1
  7. data/app/controllers/completion_kit/prompts_controller.rb +1 -0
  8. data/app/controllers/completion_kit/runs_controller.rb +16 -9
  9. data/app/jobs/completion_kit/generate_row_job.rb +1 -1
  10. data/app/jobs/completion_kit/judge_review_job.rb +1 -2
  11. data/app/jobs/completion_kit/run_completion_check_job.rb +4 -0
  12. data/app/jobs/completion_kit/start_run_job.rb +29 -0
  13. data/app/models/completion_kit/prompt_serve.rb +41 -0
  14. data/app/models/completion_kit/run.rb +110 -14
  15. data/app/services/completion_kit/dashboard_stats.rb +54 -0
  16. data/app/services/completion_kit/judge_service.rb +4 -1
  17. data/app/services/completion_kit/mcp_tools/base.rb +12 -0
  18. data/app/services/completion_kit/mcp_tools/responses.rb +38 -3
  19. data/app/services/completion_kit/mcp_tools/runs.rb +33 -5
  20. data/app/services/completion_kit/metric_improvement_validator.rb +1 -2
  21. data/app/services/completion_kit/prompt_improvement_validator.rb +2 -3
  22. data/app/services/completion_kit/response_query.rb +94 -0
  23. data/app/views/completion_kit/api_reference/_body.html.erb +5 -3
  24. data/app/views/completion_kit/dashboard/_failing_checks_card.html.erb +36 -7
  25. data/app/views/completion_kit/dashboard/_failures_card.html.erb +6 -5
  26. data/app/views/completion_kit/dashboard/_worst_metric_card.html.erb +1 -1
  27. data/app/views/completion_kit/dashboard/show.html.erb +48 -9
  28. data/app/views/completion_kit/prompts/show.html.erb +13 -0
  29. data/app/views/completion_kit/responses/show.html.erb +1 -1
  30. data/app/views/completion_kit/runs/_form.html.erb +17 -0
  31. data/app/views/completion_kit/runs/_responses_region.html.erb +20 -0
  32. data/app/views/completion_kit/runs/_status_header.html.erb +22 -0
  33. data/app/views/completion_kit/runs/_status_panel.html.erb +18 -0
  34. data/app/views/completion_kit/runs/show.html.erb +30 -30
  35. data/db/migrate/20260728000001_add_max_tokens_to_completion_kit_runs.rb +5 -0
  36. data/db/migrate/20260730000001_add_judge_temperature_to_completion_kit_runs.rb +5 -0
  37. data/db/migrate/20260730000002_create_completion_kit_prompt_serves.rb +17 -0
  38. data/lib/completion_kit/version.rb +1 -1
  39. data/lib/completion_kit.rb +4 -1
  40. metadata +8 -1
@@ -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
- inputSchema: {type: "object", properties: {run_id: {type: "integer"}}, required: ["run_id"]},
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
- text_result(run.responses.includes(:reviews).map(&:as_json))
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
- return json unless run.metric_ids.empty?
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" => "No metrics are attached, so this run judges nothing. Attach metric_ids or a metric_group_id before generating.")
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(config).evaluate(
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, model: model, temperature: @run.temperature)
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
- config = ApiConfig.for_model(@run.judge_model).merge(judge_model: @run.judge_model)
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>&ensp;<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>&ensp;<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>&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
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>&ensp;<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
- <%= 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 \\\n -H \"Authorization: Bearer #{token}\"" %>
189
+ <p class="ck-api-params"><strong>Optional filters:</strong>&ensp;<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>&ensp;<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">Failing checks · last 7 days</p>
4
+ <p class="ck-kicker">Checks · 14D</p>
3
5
  <div class="ck-stat-card__body">
4
- <span class="ck-stat-card__count<%= failing_checks[:count].positive? ? ' is-danger' : ' is-clean' %>"><%= failing_checks[:count] %></span>
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(5).each do |item| %>
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">check</span>
12
- <%= link_to item[:metric_name], completion_kit.run_path(item[:run]), class: "ck-link ck-failure-list__cause" %>
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 · last 7 days</p>
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[:cause], completion_kit.run_path(item[:run]), class: "ck-link ck-failure-list__cause" %>
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"><%= item[:cause] %></span>
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 nothing failed this week.<% end %></span>
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 · last 7 days</p>
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 · last 14 days</p>
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-sparkline" role="img" aria-label="<%= @activity.sum { |d| d[:count] } %> runs over the last 14 days">
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
- <p class="ck-stat-card__foot">
43
- <span class="ck-stat-card__figure"><%= @activity.sum { |d| d[:count] } %></span> runs in the window
44
- </p>
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
- <%= render "completion_kit/dashboard/failing_checks_card",
54
- failing_checks: @failing_checks %>
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">Review</p>
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 %>
@@ -93,6 +93,14 @@
93
93
  </div>
94
94
  </div>
95
95
 
96
+ <div class="ck-field">
97
+ <label class="ck-label" for="run_max_tokens" style="position: relative;">
98
+ Max tokens<span class="ck-info-toggle" tabindex="0">?</span><span class="ck-info-popup">The ceiling on how many tokens the model may generate per row. Leave it blank to use the provider's default. Set it when your prompt produces long output: if generation hits the cap the response is cut off mid-sentence, and the judge scores the truncation rather than the prompt. Match whatever your production caller uses so the evaluation reflects reality.</span>
99
+ </label>
100
+ <%= form.number_field :max_tokens, min: 1, step: 1, class: "ck-input", id: "run_max_tokens", placeholder: "Provider default", **ck_field_aria(form, :max_tokens) %>
101
+ <%= ck_field_error(form, :max_tokens) %>
102
+ </div>
103
+
96
104
  <div class="ck-field" id="judge-field">
97
105
  <label class="ck-label" for="run_judge_model" style="position: relative;">
98
106
  Judge model<span class="ck-info-toggle" tabindex="0">?</span><span class="ck-info-popup">Judge models score generated responses against your metrics. Pick one when configuring a run.</span>
@@ -118,6 +126,15 @@
118
126
  <% end %>
119
127
  </div>
120
128
 
129
+ <div class="ck-field" id="judge-temperature-field">
130
+ <label class="ck-label" for="run_judge_temperature" style="position: relative;">
131
+ Judge temperature<span class="ck-info-toggle" tabindex="0">?</span><span class="ck-info-popup">How random the judge is when it scores. This is separate from the generation temperature above. Keep it at 0 so re-judging the same output gives the same score, which is what makes one run comparable to another. Raise it only when you are deliberately measuring how much the judge disagrees with itself.</span>
132
+ </label>
133
+ <%= form.number_field :judge_temperature, in: 0..1, step: 0.1, class: "ck-input", id: "run_judge_temperature", **ck_field_aria(form, :judge_temperature) %>
134
+ <%= ck_field_error(form, :judge_temperature) %>
135
+ <p class="ck-field-hint" id="judge-temperature-hint">0 is deterministic and recommended. Above 0, the same output can score differently on a re-judge.</p>
136
+ </div>
137
+
121
138
  <div class="ck-field" id="metrics-field">
122
139
  <p class="ck-label" id="run_metrics_label">Metrics</p>
123
140
  <% if @all_metrics.empty? %>
@@ -0,0 +1,20 @@
1
+ <div id="run_responses_region">
2
+ <p class="ck-kicker" style="margin-top: 1.5rem;<%= ' display:none;' unless responses.any? %>" id="responses_kicker">Responses</p>
3
+ <table class="ck-results-table ck-responses-table"<%= ' hidden'.html_safe unless responses.any? %> id="responses_table">
4
+ <thead>
5
+ <tr>
6
+ <th scope="col">#</th>
7
+ <th scope="col">Response</th>
8
+ <th scope="col">Metrics</th>
9
+ <th scope="col">Avg score</th>
10
+ <th scope="col">Status</th>
11
+ <th scope="col"></th>
12
+ </tr>
13
+ </thead>
14
+ <tbody id="run_responses">
15
+ <% responses.each_with_index do |response, idx| %>
16
+ <%= render "response_row", run: run, response: response, index: responses_offset + idx + 1 %>
17
+ <% end %>
18
+ </tbody>
19
+ </table>
20
+ </div>
@@ -12,6 +12,28 @@
12
12
  </div>
13
13
  <% end %>
14
14
 
15
+ <% if run.scores_at_ceiling? %>
16
+ <div class="ck-score-ceiling-banner" role="status">
17
+ <div class="ck-score-ceiling-banner__body">
18
+ <p class="ck-kicker">Scores are clustered near the top</p>
19
+ <p class="ck-meta-copy">
20
+ A near-perfect average usually means the judge is not separating good output from bad, not that every
21
+ output is good. Check it against your own reading before you trust the number.
22
+ </p>
23
+ </div>
24
+ <div class="ck-score-ceiling-banner__actions">
25
+ <%= link_to "Worst first", run_path(run, sort: "score_asc"),
26
+ class: ck_button_classes(:light, variant: :outline) + " ck-button--sm",
27
+ title: "Sort the responses lowest score first to see whether the judge ever penalises anything." %>
28
+ <% if CompletionKit.config.judge_agreement_enabled && run.calibratable_metric %>
29
+ <%= link_to "Check the judge", metric_path(run.calibratable_metric, anchor: "agreement"),
30
+ class: ck_button_classes(:dark) + " ck-button--sm",
31
+ title: "Score a few outputs yourself and see how often the judge agrees with you." %>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+ <% end %>
36
+
15
37
  <section class="ck-page-header">
16
38
  <div>
17
39
  <span class="ck-status-badge ck-status-badge--<%= run.status %>">