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.
Files changed (41) 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/metric.rb +1 -0
  14. data/app/models/completion_kit/prompt_serve.rb +41 -0
  15. data/app/models/completion_kit/run.rb +127 -16
  16. data/app/services/completion_kit/dashboard_stats.rb +54 -0
  17. data/app/services/completion_kit/judge_service.rb +4 -1
  18. data/app/services/completion_kit/mcp_tools/base.rb +12 -0
  19. data/app/services/completion_kit/mcp_tools/responses.rb +38 -3
  20. data/app/services/completion_kit/mcp_tools/runs.rb +33 -5
  21. data/app/services/completion_kit/metric_improvement_validator.rb +1 -2
  22. data/app/services/completion_kit/prompt_improvement_validator.rb +2 -3
  23. data/app/services/completion_kit/response_query.rb +94 -0
  24. data/app/views/completion_kit/api_reference/_body.html.erb +5 -3
  25. data/app/views/completion_kit/dashboard/_failing_checks_card.html.erb +36 -7
  26. data/app/views/completion_kit/dashboard/_failures_card.html.erb +6 -5
  27. data/app/views/completion_kit/dashboard/_worst_metric_card.html.erb +1 -1
  28. data/app/views/completion_kit/dashboard/show.html.erb +48 -9
  29. data/app/views/completion_kit/prompts/show.html.erb +13 -0
  30. data/app/views/completion_kit/responses/show.html.erb +1 -1
  31. data/app/views/completion_kit/runs/_form.html.erb +17 -0
  32. data/app/views/completion_kit/runs/_responses_region.html.erb +20 -0
  33. data/app/views/completion_kit/runs/_status_header.html.erb +22 -0
  34. data/app/views/completion_kit/runs/_status_panel.html.erb +18 -0
  35. data/app/views/completion_kit/runs/show.html.erb +30 -30
  36. data/db/migrate/20260728000001_add_max_tokens_to_completion_kit_runs.rb +5 -0
  37. data/db/migrate/20260730000001_add_judge_temperature_to_completion_kit_runs.rb +5 -0
  38. data/db/migrate/20260730000002_create_completion_kit_prompt_serves.rb +17 -0
  39. data/lib/completion_kit/version.rb +1 -1
  40. data/lib/completion_kit.rb +4 -1
  41. 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
- 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 %>