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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84d83f7eb0d478096935bae36f7f274a99aeccf105f4ba7a02243dde5aa0e236
4
- data.tar.gz: 78ae0b7e08b138cebdc97a09b6d97d0972169bc323bcbc5060c76cf06c754156
3
+ metadata.gz: 58dfff9f577e36bd13a32f6670ad1c095c23492238fd50043bd2ccceca648e98
4
+ data.tar.gz: 0c8a0d5e4c96e8bccff0a93ede04aef40c650255f1271162537a8a63008314a6
5
5
  SHA512:
6
- metadata.gz: 980fbda1a34e14c268ec20c5d4401448cd695503af3edef039c77a78033bb984be7f3d92336847cad328545d5342ae6a4d6982f5b777bd48b86196d0a2aac0f7
7
- data.tar.gz: 93857d377169c454de5c46bc865e7022febe6b728af513d66a856e3985144e3f6e3b4ed7829306d13c36e0b445e3c0ba44fb476ba5588e98e771737c3e02e4f1
6
+ metadata.gz: 15b9d07c9bd067885ab2d47ed580d7237dd49e1f6e0b64874a46664db8f02d5c0f1cdde7b183431c9244769a6b96bbb4279d8c5062fc890755bf9b43542ca4cb
7
+ data.tar.gz: '08fd16f17461b4960aecffdcc350347120d2a5d786327d982adfab7f3b0cfcdaa1e011eb7e81ec5773c39390baa48f3477bed115358e2d4a043205acd33a58f9'
@@ -519,8 +519,16 @@ form.button_to {
519
519
  font-size: 2.6rem;
520
520
  line-height: 1;
521
521
  }
522
- .ck-stat-card__count.is-clean { color: var(--ck-success, #4ade80); }
523
- .ck-stat-card__count.is-danger { color: var(--ck-danger, #f87171); }
522
+ .ck-stat-card__count.is-clean,
523
+ .ck-stat-card__count.is-high { color: var(--ck-success, #4ade80); }
524
+ .ck-stat-card__count.is-medium { color: var(--ck-warning); }
525
+ .ck-stat-card__count.is-danger,
526
+ .ck-stat-card__count.is-low { color: var(--ck-danger, #f87171); }
527
+ .ck-stat-card__unit {
528
+ font-family: var(--ck-mono);
529
+ font-size: 0.78rem;
530
+ color: var(--ck-muted);
531
+ }
524
532
 
525
533
  /* Dashboard activity sparkline — fixed-height row of bars, height set
526
534
  inline per day. Bars hug the bottom; quiet days collapse to a sliver.
@@ -542,8 +550,17 @@ form.button_to {
542
550
  transition: background 0.15s ease;
543
551
  }
544
552
  .ck-sparkline__bar.is-peak { background: var(--ck-accent); }
553
+ .ck-sparkline__bar.is-medium { background: var(--ck-warning); }
554
+ .ck-sparkline__bar.is-low { background: var(--ck-danger); }
545
555
  .ck-sparkline__bar:hover { background: var(--ck-accent-hover); }
546
556
 
557
+ /* The number carries the headline; the bars are supporting texture, so they
558
+ take about half the height of a standalone sparkline. */
559
+ .ck-sparkline--compact {
560
+ height: 1.9rem;
561
+ margin: 0.55rem 0 0;
562
+ }
563
+
547
564
  /* One orchestrated page-load reveal — the dashboard "boots up". */
548
565
  @keyframes ck-rise {
549
566
  from { opacity: 0; transform: translateY(8px); }
@@ -1295,6 +1312,25 @@ tr:hover .ck-chip--publish {
1295
1312
  margin: 0 0.15rem;
1296
1313
  }
1297
1314
 
1315
+ .ck-run-status__cell-value--split {
1316
+ gap: 0.5rem;
1317
+ flex-wrap: wrap;
1318
+ }
1319
+
1320
+ .ck-run-status__cell-value--stack {
1321
+ flex-direction: column;
1322
+ align-items: flex-start;
1323
+ justify-content: center;
1324
+ gap: 0.15rem;
1325
+ }
1326
+
1327
+ .ck-run-status__cell-unverified {
1328
+ font-family: var(--ck-mono);
1329
+ font-size: 0.95rem;
1330
+ color: var(--ck-warning);
1331
+ letter-spacing: 0.02em;
1332
+ }
1333
+
1298
1334
  .ck-run-status__cell-empty {
1299
1335
  font-family: var(--ck-mono);
1300
1336
  font-size: 1.4rem;
@@ -3184,7 +3220,8 @@ select.ck-input {
3184
3220
  line-height: 1.55;
3185
3221
  }
3186
3222
 
3187
- .ck-stale-versions-banner {
3223
+ .ck-stale-versions-banner,
3224
+ .ck-score-ceiling-banner {
3188
3225
  margin: 0 0 1rem;
3189
3226
  padding: 0.9rem 1rem;
3190
3227
  border: 1px solid rgba(224, 164, 88, 0.4);
@@ -3196,8 +3233,33 @@ select.ck-input {
3196
3233
  gap: 1rem;
3197
3234
  flex-wrap: wrap;
3198
3235
  }
3199
- .ck-stale-versions-banner__body { min-width: 0; flex: 1 1 320px; }
3200
- .ck-stale-versions-banner .ck-kicker { color: var(--ck-warning); }
3236
+ .ck-stale-versions-banner__body,
3237
+ .ck-score-ceiling-banner__body { min-width: 0; flex: 1 1 320px; }
3238
+ .ck-stale-versions-banner .ck-kicker,
3239
+ .ck-score-ceiling-banner .ck-kicker { color: var(--ck-warning); }
3240
+ .ck-score-ceiling-banner__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
3241
+
3242
+ /* Each action carries its own consequence line, because the difference
3243
+ between re-grading and re-running is the whole decision. */
3244
+ .ck-banner-actions {
3245
+ display: flex;
3246
+ gap: 1.25rem;
3247
+ flex-wrap: wrap;
3248
+ align-items: flex-start;
3249
+ }
3250
+ .ck-banner-action {
3251
+ display: flex;
3252
+ flex-direction: column;
3253
+ gap: 0.35rem;
3254
+ max-width: 16rem;
3255
+ }
3256
+ .ck-banner-action__note {
3257
+ margin: 0;
3258
+ font-family: var(--ck-mono);
3259
+ font-size: 0.7rem;
3260
+ line-height: 1.45;
3261
+ color: var(--ck-muted);
3262
+ }
3201
3263
 
3202
3264
  .ck-delta {
3203
3265
  font-family: var(--ck-mono);
@@ -6557,3 +6619,9 @@ a.tag-mark {
6557
6619
  outline: 2px solid var(--ck-accent);
6558
6620
  outline-offset: 2px;
6559
6621
  }
6622
+
6623
+ .ck-serve-figure {
6624
+ font-family: var(--ck-mono);
6625
+ font-weight: 700;
6626
+ color: var(--ck-text);
6627
+ }
@@ -11,6 +11,7 @@ module CompletionKit
11
11
  end
12
12
 
13
13
  def show
14
+ PromptServe.record!(@prompt)
14
15
  render json: @prompt
15
16
  end
16
17
 
@@ -6,9 +6,12 @@ module CompletionKit
6
6
  before_action :set_response, only: [:show]
7
7
 
8
8
  def index
9
- scope = @run.responses.includes(:reviews)
10
- scope = scope.where(status: params[:status]) if params[:status].present?
11
- render json: paginate(scope.order(:id))
9
+ query = ResponseQuery.new(
10
+ @run,
11
+ status: params[:status], min_score: params[:min_score], max_score: params[:max_score],
12
+ sort: params[:sort], fields: params[:fields]
13
+ )
14
+ render json: paginate(query.relation).map { |response| query.serialize(response) }
12
15
  end
13
16
 
14
17
  def show
@@ -130,7 +130,7 @@ module CompletionKit
130
130
  end
131
131
 
132
132
  def run_params
133
- params.permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, :output_column, :expected_column,
133
+ params.permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, :max_tokens, :judge_temperature, :output_column, :expected_column,
134
134
  metric_ids: [], tag_names: [])
135
135
  end
136
136
  end
@@ -9,12 +9,23 @@ module CompletionKit
9
9
  @metric_count = Metric.count
10
10
  @recent_runs = Run.display_scoped.order(created_at: :desc).limit(5)
11
11
 
12
+ # Serving is independent of evaluating: a workspace that only fetches
13
+ # prompts over the API and never runs an eval is exactly the audience for
14
+ # this, so it sits outside the run-count gate below.
15
+ @top_served = DashboardStats.top_served(since: 7.days.ago)
16
+ @serve_activity = DashboardStats.serve_activity
17
+
12
18
  return unless Run.count > 5
13
19
 
14
20
  @activity = DashboardStats.activity
15
21
  @worst_metric = DashboardStats.worst_metric(since: 7.days.ago)
16
22
  @failures = DashboardStats.failures(since: 7.days.ago)
17
- @failing_checks = DashboardStats.failing_checks(since: 7.days.ago)
23
+ # A workspace with no check metrics can never populate this card, so it
24
+ # would sit at a permanent zero and read as filler.
25
+ if Metric.exists?(metric_type: "check")
26
+ @failing_checks = DashboardStats.failing_checks(since: 14.days.ago)
27
+ @check_activity = DashboardStats.check_activity
28
+ end
18
29
  @ignored_metrics = DashboardDismissal.metrics
19
30
  @ignored_failures = DashboardDismissal.failures
20
31
  @prompt_changes = DashboardStats.prompt_changes
@@ -8,6 +8,7 @@ module CompletionKit
8
8
  end
9
9
 
10
10
  def show
11
+ @serve_summary = PromptServe.summary_for(@prompt)
11
12
  @runs = Run.where(prompt_id: @prompt.family_versions.select(:id))
12
13
  .includes(:prompt, :dataset, :tags, responses: :reviews)
13
14
  .order(created_at: :desc)
@@ -119,14 +119,21 @@ module CompletionKit
119
119
  end
120
120
  end
121
121
 
122
+ # The web process drives the live run page, because worker-side Turbo
123
+ # broadcasts do not reliably publish in production. Since a run is now
124
+ # started asynchronously, this has to carry the rows and the progress panel
125
+ # too, not just the status badge, or the page sits empty until a reload.
122
126
  def refresh_status
127
+ show
128
+
123
129
  respond_to do |format|
124
130
  format.turbo_stream do
125
- render turbo_stream: turbo_stream.replace(
126
- "run_status_header",
127
- partial: "completion_kit/runs/status_header",
128
- locals: { run: @run }
129
- )
131
+ render turbo_stream: [
132
+ turbo_stream.replace("run_status_header", partial: "completion_kit/runs/status_header", locals: { run: @run }),
133
+ turbo_stream.replace("run_status_panel", partial: "completion_kit/runs/status_panel", locals: { run: @run }),
134
+ turbo_stream.replace("run_responses_region", partial: "completion_kit/runs/responses_region",
135
+ locals: { run: @run, responses: @responses, responses_offset: @responses_offset })
136
+ ]
130
137
  end
131
138
  end
132
139
  end
@@ -215,13 +222,13 @@ module CompletionKit
215
222
  end
216
223
 
217
224
  def run_params
218
- params.require(:run).permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, :output_column, :expected_column, metric_ids: [], tag_names: [])
225
+ params.require(:run).permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, :max_tokens, :judge_temperature, :output_column, :expected_column, metric_ids: [], tag_names: [])
219
226
  end
220
227
 
221
228
  # Editing a run that already has results forks a new run — but only when a
222
229
  # field that affects generation or judging changed. Renaming or retagging is
223
230
  # pure metadata and updates the run in place.
224
- GENERATION_RUN_FIELDS = %i[prompt_id dataset_id judge_model temperature output_column expected_column].freeze
231
+ GENERATION_RUN_FIELDS = %i[prompt_id dataset_id judge_model temperature max_tokens judge_temperature output_column expected_column].freeze
225
232
 
226
233
  def run_generation_changed?
227
234
  GENERATION_RUN_FIELDS.each do |field|
@@ -236,8 +243,8 @@ module CompletionKit
236
243
  s = value.to_s.strip
237
244
  return nil if s.empty?
238
245
  case field
239
- when :temperature then s.to_f
240
- when :prompt_id, :dataset_id then s.to_i
246
+ when :temperature, :judge_temperature then s.to_f
247
+ when :prompt_id, :dataset_id, :max_tokens then s.to_i
241
248
  else s
242
249
  end
243
250
  end
@@ -58,7 +58,7 @@ module CompletionKit
58
58
 
59
59
  raise ConfigurationError, client.configuration_errors.join(", ") unless client.configured?
60
60
 
61
- text = client.generate_completion(rendered, model: prompt.llm_model, temperature: run.temperature)
61
+ text = client.generate_completion(rendered, **run.generation_options(prompt))
62
62
 
63
63
  if text.to_s.start_with?("Error:")
64
64
  record_terminal_failure!(ProviderError.from_client_error(text))
@@ -59,8 +59,7 @@ module CompletionKit
59
59
  metric = Metric.find(metric_id)
60
60
  run = response.run
61
61
 
62
- config = ApiConfig.for_model(run.judge_model).merge(judge_model: run.judge_model)
63
- judge = JudgeService.new(config)
62
+ judge = JudgeService.new(run.judge_config)
64
63
 
65
64
  begin
66
65
  evaluation = judge.evaluate(
@@ -10,6 +10,10 @@ module CompletionKit
10
10
  run = Run.find_by(id: run_id)
11
11
  return unless run
12
12
  return unless run.status == "running"
13
+ # A run that has been claimed but whose rows are still being inserted by
14
+ # StartRunJob has no outstanding work yet, and would otherwise complete
15
+ # itself with zero responses.
16
+ return if run.progress_total.zero?
13
17
  return unless run.outstanding_work_zero?
14
18
 
15
19
  run.mark_completed!
@@ -0,0 +1,29 @@
1
+ module CompletionKit
2
+ class StartRunJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ limits_concurrency to: 1,
6
+ key: ->(run_id, _defaults = {}) { "run:#{run_id}:start" },
7
+ duration: 10.minutes
8
+
9
+ rescue_from(StandardError) do |error|
10
+ Rails.error.report(error, handled: true, context: { job: self.class.name, run_id: @run_id })
11
+ run = Run.find_by(id: @run_id)
12
+ run&.fail_to_start!(error.message)
13
+ end
14
+
15
+ # scope_defaults carries the host's tenant columns, resolved in the web
16
+ # request where the tenant context exists. A worker has no such context, so
17
+ # without them the inserted responses would be stamped with nothing.
18
+ def perform(run_id, scope_defaults = {})
19
+ @run_id = run_id
20
+
21
+ run = Run.find_by(id: run_id)
22
+ return unless run
23
+ return unless run.status == "running"
24
+ return if run.responses.exists?
25
+
26
+ run.execute_start!(scope_defaults.symbolize_keys)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ module CompletionKit
2
+ # One row per prompt per day, counting how often that prompt was fetched by a
3
+ # consumer. Rolled up daily rather than stored per request so the table stays
4
+ # bounded by (prompts x days) instead of growing with traffic.
5
+ #
6
+ # family_key is denormalised so a family's serving history survives deleting
7
+ # an individual version, which prompt_id alone would not.
8
+ class PromptServe < ApplicationRecord
9
+ self.table_name = "completion_kit_prompt_serves"
10
+
11
+ belongs_to :prompt, optional: true
12
+
13
+ def self.record!(prompt)
14
+ today = Date.current
15
+ now = Time.current
16
+ scope = where(prompt_id: prompt.id, served_on: today)
17
+
18
+ return if scope.update_all(["serve_count = serve_count + 1, last_served_at = ?", now]).positive?
19
+
20
+ create!(prompt_id: prompt.id, family_key: prompt.family_key, served_on: today,
21
+ serve_count: 1, last_served_at: now)
22
+ rescue ActiveRecord::RecordNotUnique
23
+ scope.update_all(["serve_count = serve_count + 1, last_served_at = ?", now])
24
+ end
25
+
26
+ # Totals for the whole prompt family, so publishing a new version does not
27
+ # reset the number a user is watching.
28
+ def self.summary_for(prompt, windows: [7, 30])
29
+ rows = where(family_key: prompt.family_key)
30
+ .pluck(:served_on, :serve_count, :last_served_at)
31
+ return { total: 0, last_served_at: nil }.merge(windows.index_with { 0 }) if rows.empty?
32
+
33
+ summary = { total: rows.sum { |_, count, _| count },
34
+ last_served_at: rows.filter_map { |_, _, at| at }.max }
35
+ windows.each_with_object(summary) do |days, acc|
36
+ cutoff = Date.current - (days - 1)
37
+ acc[days] = rows.sum { |on, count, _| on >= cutoff ? count : 0 }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -18,6 +18,8 @@ module CompletionKit
18
18
 
19
19
  validates :name, presence: true
20
20
  validates :status, inclusion: { in: STATUSES }
21
+ validates :max_tokens, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
22
+ validates :judge_temperature, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
21
23
  validate :dataset_supplies_prompt_variables
22
24
  validate :judge_only_run_supplies_output_column
23
25
  validate :dataset_supplies_expected_column
@@ -65,6 +67,7 @@ module CompletionKit
65
67
  :metric_name,
66
68
  Arel.sql("AVG(ai_score)"),
67
69
  Arel.sql("COUNT(ai_score)"),
70
+ Arel.sql("SUM(CASE WHEN ai_score < #{low_score_ceiling} THEN 1 ELSE 0 END)"),
68
71
  Arel.sql("COUNT(passed)"),
69
72
  Arel.sql("SUM(CASE WHEN passed THEN 1 ELSE 0 END)")
70
73
  )
@@ -77,17 +80,61 @@ module CompletionKit
77
80
  run.avg_score = stats && stats[:avg] ? stats[:avg].to_f.round(2) : nil
78
81
  run.check_pass_rate = stats && stats[:resolved] > 0 ? (stats[:passed].to_f / stats[:resolved]).round(2) : nil
79
82
 
80
- run.metric_averages = (metrics_by_run[run.id] || []).filter_map do |(_rid, name, avg, scored, resolved, passed)|
83
+ run.metric_averages = (metrics_by_run[run.id] || []).filter_map do |(_rid, name, avg, scored, low, resolved, passed)|
81
84
  if scored.to_i > 0
82
- {name: name, avg: avg.to_f.round(1)}
85
+ {name: name, avg: avg.to_f.round(1), count: scored.to_i, low_count: low.to_i}
83
86
  elsif resolved.to_i > 0
84
- {name: name, kind: "check", pass_rate: (passed.to_i.to_f / resolved.to_i).round(2)}
87
+ {name: name, kind: "check", pass_rate: (passed.to_i.to_f / resolved.to_i).round(2),
88
+ count: resolved.to_i, low_count: resolved.to_i - passed.to_i}
85
89
  end
86
90
  end
87
91
  end
88
92
  runs
89
93
  end
90
94
 
95
+ # Scores below this are the ones worth reading: `low_count` on each metric
96
+ # average counts them, so a caller can spot the dragging metric without
97
+ # pulling every review.
98
+ def self.low_score_ceiling
99
+ CompletionKit.config.medium_quality_threshold.to_f
100
+ end
101
+
102
+ TOP_SCORE = 5.0
103
+ CEILING_MEAN = 4.8
104
+ CEILING_TOP_SHARE = 0.9
105
+ CEILING_MIN_REVIEWS = 10
106
+
107
+ # A judge that scores almost everything at the top is usually failing to
108
+ # separate good output from bad, which reads as success to anyone who has
109
+ # not calibrated it. Detected either as a near-max mean or as nearly every
110
+ # score landing on the top band, and only once there are enough scores for
111
+ # the shape to mean anything.
112
+ def scores_at_ceiling?
113
+ return false unless status == "completed"
114
+
115
+ scores = reviews_for_summary.filter_map { |review| review.ai_score&.to_f }
116
+ return false if scores.length < CEILING_MIN_REVIEWS
117
+ return true if (scores.sum / scores.length) >= CEILING_MEAN
118
+
119
+ (scores.count { |score| score >= TOP_SCORE }.to_f / scores.length) >= CEILING_TOP_SHARE
120
+ end
121
+
122
+ def calibratable_metric
123
+ llm_metrics.first
124
+ end
125
+
126
+ # How often a human agreed with the judge on this run's own responses.
127
+ # Scoped to the run rather than to a metric's current version, so the
128
+ # figure describes the scores actually shown on this page. Uses the same
129
+ # Wilson point as MetricAgreementStats so the two surfaces never disagree.
130
+ def judge_agreement
131
+ verdicts = Agreement.where(run_id: id).pluck(:verdict)
132
+ return nil if verdicts.empty?
133
+
134
+ point = AgreementMath.wilson_interval(successes: verdicts.count { |v| v == "agree" }, n: verdicts.length)[:point]
135
+ { rate: point, sample_size: verdicts.length }
136
+ end
137
+
91
138
  # A scoring-only run grades a pre-existing column on the dataset instead of
92
139
  # generating new outputs. No prompt is attached; the response text is read
93
140
  # from row[output_column]; no LLM generation happens.
@@ -202,17 +249,20 @@ module CompletionKit
202
249
  def metric_averages
203
250
  return @metric_averages if defined?(@metric_averages)
204
251
 
252
+ ceiling = self.class.low_score_ceiling
205
253
  reviews_for_summary.group_by(&:metric_name).filter_map do |name, reviews|
206
254
  scored = reviews.select { |r| r.ai_score.present? }
207
255
  if scored.any?
208
256
  scores = scored.map { |r| r.ai_score.to_f }
209
- { name: name, avg: (scores.sum / scores.length).round(1) }
257
+ { name: name, avg: (scores.sum / scores.length).round(1), count: scores.length,
258
+ low_count: scores.count { |score| score < ceiling } }
210
259
  else
211
260
  resolved = reviews.reject { |r| r.passed.nil? }
212
261
  next if resolved.empty?
213
262
 
214
263
  passed = resolved.count { |r| r.passed == true }
215
- { name: name, kind: "check", pass_rate: (passed.to_f / resolved.length).round(2) }
264
+ { name: name, kind: "check", pass_rate: (passed.to_f / resolved.length).round(2),
265
+ count: resolved.length, low_count: resolved.length - passed }
216
266
  end
217
267
  end
218
268
  end
@@ -255,18 +305,17 @@ module CompletionKit
255
305
  summary
256
306
  end
257
307
 
308
+ # Validates what can be checked cheaply, claims the run, and hands the
309
+ # expensive part (parsing the dataset, inserting a response per row, and
310
+ # enqueueing a job per row) to StartRunJob. Holding all that open inside the
311
+ # caller's request is what made runs_generate look like it had timed out
312
+ # when the run had in fact started.
258
313
  def start!
259
314
  unless %w[pending failed].include?(status)
260
315
  return fail_with_summary!("Cannot start a run in state \"#{status}\". Use rerun to create a fresh copy, or retry_failures / regrade to work with the existing responses.")
261
316
  end
262
317
 
263
- rows = if dataset
264
- CsvProcessor.process_self(self)
265
- else
266
- [{}]
267
- end
268
-
269
- return fail_with_summary!("Dataset has no rows") if rows.empty?
318
+ return fail_with_summary!("Dataset has no rows") if dataset && dataset.row_count.zero?
270
319
 
271
320
  if judge_only?
272
321
  column = output_column.presence || "actual_output"
@@ -286,17 +335,39 @@ module CompletionKit
286
335
  update!(
287
336
  status: "running",
288
337
  progress_current: 0,
289
- progress_total: rows.length,
338
+ progress_total: 0,
290
339
  failure_summary: nil,
291
340
  error_message: nil
292
341
  )
342
+ end
343
+ rescue ActiveRecord::RecordInvalid => e
344
+ reload
345
+ return fail_with_summary!(e.record.errors.full_messages.to_sentence)
346
+ end
347
+
348
+ StartRunJob.perform_later(id, Response.all.where_values_hash.symbolize_keys)
349
+ safely_broadcast { broadcast_ui }
350
+ true
351
+ end
352
+
353
+ def fail_to_start!(message)
354
+ fail_with_summary!(message)
355
+ end
356
+
357
+ def execute_start!(scope_defaults = {})
358
+ rows = dataset ? CsvProcessor.process_self(self) : [{}]
359
+
360
+ return fail_with_summary!("Dataset has no rows") if rows.empty?
361
+
362
+ begin
363
+ transaction do
364
+ update!(progress_current: 0, progress_total: rows.length)
293
365
 
294
366
  now = Time.current
295
367
  out_col = output_column.presence || "actual_output"
296
368
  exp_col = expected_column.presence || "expected_output"
297
369
  judge = judge_only?
298
370
  has_output = judge && dataset && dataset.headers.include?(out_col)
299
- scope_defaults = Response.all.where_values_hash.symbolize_keys
300
371
 
301
372
  response_attrs = rows.each_with_index.map do |row, index|
302
373
  {
@@ -378,12 +449,35 @@ module CompletionKit
378
449
  true
379
450
  end
380
451
 
452
+ # The options every generation call for this run sends to the provider.
453
+ # max_tokens is omitted when unset so each client keeps its own default;
454
+ # setting it is how a run reproduces a production cap and stops the judge
455
+ # scoring truncated output.
456
+ def generation_options(prompt)
457
+ options = {model: prompt.llm_model, temperature: temperature}
458
+ options[:max_tokens] = max_tokens if max_tokens
459
+ options
460
+ end
461
+
462
+ # Everything JudgeService needs to score this run. Judging defaults to
463
+ # temperature 0 so re-judging the same output yields the same score;
464
+ # anything above that makes the run's numbers irreproducible.
465
+ def judge_config
466
+ ApiConfig.for_model(judge_model).merge(judge_model: judge_model, judge_temperature: judge_temperature)
467
+ end
468
+
469
+ def nondeterministic_judge?
470
+ judge_temperature.to_f > 0
471
+ end
472
+
381
473
  def rerun!
382
474
  new_run = Run.create!(
383
475
  prompt_id: prompt_id,
384
476
  dataset_id: dataset_id,
385
477
  judge_model: judge_model,
386
478
  temperature: temperature,
479
+ max_tokens: max_tokens,
480
+ judge_temperature: judge_temperature,
387
481
  output_column: output_column,
388
482
  expected_column: expected_column,
389
483
  tag_names: tag_names,
@@ -464,8 +558,10 @@ module CompletionKit
464
558
  output_column: output_column,
465
559
  expected_column: expected_column,
466
560
  created_at: created_at, updated_at: updated_at,
561
+ max_tokens: max_tokens, judge_temperature: judge_temperature,
467
562
  responses_count: responses.count, avg_score: avg_score,
468
563
  check_pass_rate: check_pass_rate,
564
+ metric_averages: metric_averages,
469
565
  progress_current: snap[:generated_done],
470
566
  progress_total: snap[:generated_total],
471
567
  progress: {
@@ -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