completion-kit 0.28.23 → 0.28.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/completion_kit/application.css +73 -5
- data/app/controllers/completion_kit/api/v1/prompts_controller.rb +1 -0
- data/app/controllers/completion_kit/api/v1/responses_controller.rb +6 -3
- data/app/controllers/completion_kit/api/v1/runs_controller.rb +1 -1
- data/app/controllers/completion_kit/dashboard_controller.rb +12 -1
- data/app/controllers/completion_kit/prompts_controller.rb +1 -0
- data/app/controllers/completion_kit/runs_controller.rb +16 -9
- data/app/jobs/completion_kit/generate_row_job.rb +1 -1
- data/app/jobs/completion_kit/judge_review_job.rb +1 -2
- data/app/jobs/completion_kit/run_completion_check_job.rb +4 -0
- data/app/jobs/completion_kit/start_run_job.rb +29 -0
- data/app/models/completion_kit/metric.rb +1 -0
- data/app/models/completion_kit/prompt_serve.rb +41 -0
- data/app/models/completion_kit/run.rb +127 -16
- data/app/services/completion_kit/dashboard_stats.rb +54 -0
- data/app/services/completion_kit/judge_service.rb +4 -1
- data/app/services/completion_kit/mcp_tools/base.rb +12 -0
- data/app/services/completion_kit/mcp_tools/responses.rb +38 -3
- data/app/services/completion_kit/mcp_tools/runs.rb +33 -5
- data/app/services/completion_kit/metric_improvement_validator.rb +1 -2
- data/app/services/completion_kit/prompt_improvement_validator.rb +2 -3
- data/app/services/completion_kit/response_query.rb +94 -0
- data/app/views/completion_kit/api_reference/_body.html.erb +5 -3
- data/app/views/completion_kit/dashboard/_failing_checks_card.html.erb +36 -7
- data/app/views/completion_kit/dashboard/_failures_card.html.erb +6 -5
- data/app/views/completion_kit/dashboard/_worst_metric_card.html.erb +1 -1
- data/app/views/completion_kit/dashboard/show.html.erb +48 -9
- data/app/views/completion_kit/prompts/show.html.erb +13 -0
- data/app/views/completion_kit/responses/show.html.erb +1 -1
- data/app/views/completion_kit/runs/_form.html.erb +17 -0
- data/app/views/completion_kit/runs/_responses_region.html.erb +20 -0
- data/app/views/completion_kit/runs/_status_header.html.erb +22 -0
- data/app/views/completion_kit/runs/_status_panel.html.erb +18 -0
- data/app/views/completion_kit/runs/show.html.erb +30 -30
- data/db/migrate/20260728000001_add_max_tokens_to_completion_kit_runs.rb +5 -0
- data/db/migrate/20260730000001_add_judge_temperature_to_completion_kit_runs.rb +5 -0
- data/db/migrate/20260730000002_create_completion_kit_prompt_serves.rb +17 -0
- data/lib/completion_kit/version.rb +1 -1
- data/lib/completion_kit.rb +4 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58dfff9f577e36bd13a32f6670ad1c095c23492238fd50043bd2ccceca648e98
|
|
4
|
+
data.tar.gz: 0c8a0d5e4c96e8bccff0a93ede04aef40c650255f1271162537a8a63008314a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
523
|
-
.ck-stat-card__count.is-
|
|
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
|
|
3200
|
-
.ck-
|
|
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
|
+
}
|
|
@@ -6,9 +6,12 @@ module CompletionKit
|
|
|
6
6
|
before_action :set_response, only: [:show]
|
|
7
7
|
|
|
8
8
|
def index
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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:
|
|
126
|
-
"run_status_header",
|
|
127
|
-
partial: "completion_kit/runs/
|
|
128
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
@@ -25,6 +25,7 @@ module CompletionKit
|
|
|
25
25
|
validates :name, presence: true
|
|
26
26
|
validates :key, tenant_scoped_uniqueness: { allow_nil: true }
|
|
27
27
|
validates :metric_type, inclusion: { in: METRIC_TYPES }
|
|
28
|
+
validates :instruction, presence: { message: "can't be blank. An LLM judge needs a rubric to score against." }, if: :llm_judge?
|
|
28
29
|
validate :validate_check_config, if: :check?
|
|
29
30
|
validate :metric_type_immutable_once_in_use, on: :update
|
|
30
31
|
|
|
@@ -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.
|
|
@@ -105,8 +152,12 @@ module CompletionKit
|
|
|
105
152
|
end
|
|
106
153
|
|
|
107
154
|
def mark_completed!
|
|
108
|
-
|
|
109
|
-
|
|
155
|
+
if all_responses_failed?
|
|
156
|
+
fail_with_summary!(all_failed_summary)
|
|
157
|
+
else
|
|
158
|
+
update!(status: "completed")
|
|
159
|
+
broadcast_ui
|
|
160
|
+
end
|
|
110
161
|
end
|
|
111
162
|
|
|
112
163
|
def gradable_metric_ids
|
|
@@ -198,17 +249,20 @@ module CompletionKit
|
|
|
198
249
|
def metric_averages
|
|
199
250
|
return @metric_averages if defined?(@metric_averages)
|
|
200
251
|
|
|
252
|
+
ceiling = self.class.low_score_ceiling
|
|
201
253
|
reviews_for_summary.group_by(&:metric_name).filter_map do |name, reviews|
|
|
202
254
|
scored = reviews.select { |r| r.ai_score.present? }
|
|
203
255
|
if scored.any?
|
|
204
256
|
scores = scored.map { |r| r.ai_score.to_f }
|
|
205
|
-
{ 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 } }
|
|
206
259
|
else
|
|
207
260
|
resolved = reviews.reject { |r| r.passed.nil? }
|
|
208
261
|
next if resolved.empty?
|
|
209
262
|
|
|
210
263
|
passed = resolved.count { |r| r.passed == true }
|
|
211
|
-
{ 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 }
|
|
212
266
|
end
|
|
213
267
|
end
|
|
214
268
|
end
|
|
@@ -251,18 +305,17 @@ module CompletionKit
|
|
|
251
305
|
summary
|
|
252
306
|
end
|
|
253
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.
|
|
254
313
|
def start!
|
|
255
314
|
unless %w[pending failed].include?(status)
|
|
256
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.")
|
|
257
316
|
end
|
|
258
317
|
|
|
259
|
-
rows
|
|
260
|
-
CsvProcessor.process_self(self)
|
|
261
|
-
else
|
|
262
|
-
[{}]
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
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?
|
|
266
319
|
|
|
267
320
|
if judge_only?
|
|
268
321
|
column = output_column.presence || "actual_output"
|
|
@@ -282,17 +335,39 @@ module CompletionKit
|
|
|
282
335
|
update!(
|
|
283
336
|
status: "running",
|
|
284
337
|
progress_current: 0,
|
|
285
|
-
progress_total:
|
|
338
|
+
progress_total: 0,
|
|
286
339
|
failure_summary: nil,
|
|
287
340
|
error_message: nil
|
|
288
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)
|
|
289
365
|
|
|
290
366
|
now = Time.current
|
|
291
367
|
out_col = output_column.presence || "actual_output"
|
|
292
368
|
exp_col = expected_column.presence || "expected_output"
|
|
293
369
|
judge = judge_only?
|
|
294
370
|
has_output = judge && dataset && dataset.headers.include?(out_col)
|
|
295
|
-
scope_defaults = Response.all.where_values_hash.symbolize_keys
|
|
296
371
|
|
|
297
372
|
response_attrs = rows.each_with_index.map do |row, index|
|
|
298
373
|
{
|
|
@@ -374,12 +449,35 @@ module CompletionKit
|
|
|
374
449
|
true
|
|
375
450
|
end
|
|
376
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
|
+
|
|
377
473
|
def rerun!
|
|
378
474
|
new_run = Run.create!(
|
|
379
475
|
prompt_id: prompt_id,
|
|
380
476
|
dataset_id: dataset_id,
|
|
381
477
|
judge_model: judge_model,
|
|
382
478
|
temperature: temperature,
|
|
479
|
+
max_tokens: max_tokens,
|
|
480
|
+
judge_temperature: judge_temperature,
|
|
383
481
|
output_column: output_column,
|
|
384
482
|
expected_column: expected_column,
|
|
385
483
|
tag_names: tag_names,
|
|
@@ -460,8 +558,10 @@ module CompletionKit
|
|
|
460
558
|
output_column: output_column,
|
|
461
559
|
expected_column: expected_column,
|
|
462
560
|
created_at: created_at, updated_at: updated_at,
|
|
561
|
+
max_tokens: max_tokens, judge_temperature: judge_temperature,
|
|
463
562
|
responses_count: responses.count, avg_score: avg_score,
|
|
464
563
|
check_pass_rate: check_pass_rate,
|
|
564
|
+
metric_averages: metric_averages,
|
|
465
565
|
progress_current: snap[:generated_done],
|
|
466
566
|
progress_total: snap[:generated_total],
|
|
467
567
|
progress: {
|
|
@@ -565,6 +665,17 @@ module CompletionKit
|
|
|
565
665
|
false
|
|
566
666
|
end
|
|
567
667
|
|
|
668
|
+
def all_responses_failed?
|
|
669
|
+
responses.exists? && !responses.where.not(status: "failed").exists?
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def all_failed_summary
|
|
673
|
+
total = responses.count
|
|
674
|
+
reason = responses.where(status: "failed").where.not(error_message: [nil, ""]).order(:row_index).limit(1).pick(:error_message)
|
|
675
|
+
head = "Every response failed to generate (#{total} of #{total})."
|
|
676
|
+
reason.present? ? "#{head} First error: #{reason}" : "#{head} Check the model and provider configuration."
|
|
677
|
+
end
|
|
678
|
+
|
|
568
679
|
def render_engine_partial(partial, locals)
|
|
569
680
|
CompletionKit::Engine.warm_routes!
|
|
570
681
|
CompletionKit::ApplicationController.render(
|