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.
- 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/prompt_serve.rb +41 -0
- data/app/models/completion_kit/run.rb +110 -14
- 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
|
|
@@ -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
|
|
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:
|
|
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
|