completion-kit 0.4.1 → 0.4.7

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/completion_kit/application.css +1882 -785
  3. data/app/controllers/completion_kit/runs_controller.rb +34 -19
  4. data/app/controllers/completion_kit/suggestions_controller.rb +24 -0
  5. data/app/jobs/completion_kit/generate_row_job.rb +7 -0
  6. data/app/jobs/completion_kit/judge_review_job.rb +2 -0
  7. data/app/jobs/completion_kit/model_discovery_job.rb +9 -4
  8. data/app/models/completion_kit/dataset.rb +9 -0
  9. data/app/models/completion_kit/provider_credential.rb +12 -1
  10. data/app/models/completion_kit/response.rb +7 -0
  11. data/app/models/completion_kit/run.rb +47 -9
  12. data/app/services/completion_kit/anthropic_client.rb +33 -14
  13. data/app/services/completion_kit/model_discovery_service.rb +133 -30
  14. data/app/services/completion_kit/ollama_client.rb +31 -10
  15. data/app/services/completion_kit/open_ai_client.rb +35 -13
  16. data/app/services/completion_kit/open_router_client.rb +34 -13
  17. data/app/services/completion_kit/worker_health.rb +4 -1
  18. data/app/views/completion_kit/datasets/index.html.erb +1 -1
  19. data/app/views/completion_kit/datasets/show.html.erb +47 -9
  20. data/app/views/completion_kit/metrics/_form.html.erb +1 -1
  21. data/app/views/completion_kit/metrics/index.html.erb +15 -2
  22. data/app/views/completion_kit/metrics/show.html.erb +1 -1
  23. data/app/views/completion_kit/prompts/index.html.erb +27 -8
  24. data/app/views/completion_kit/prompts/show.html.erb +6 -36
  25. data/app/views/completion_kit/provider_credentials/_discovery_status.html.erb +6 -4
  26. data/app/views/completion_kit/provider_credentials/_form.html.erb +1 -32
  27. data/app/views/completion_kit/provider_credentials/_models_card.html.erb +70 -0
  28. data/app/views/completion_kit/provider_credentials/index.html.erb +1 -1
  29. data/app/views/completion_kit/responses/show.html.erb +27 -6
  30. data/app/views/completion_kit/runs/_actions.html.erb +3 -0
  31. data/app/views/completion_kit/runs/_form.html.erb +114 -20
  32. data/app/views/completion_kit/runs/_response_row.html.erb +52 -22
  33. data/app/views/completion_kit/runs/_row.html.erb +50 -0
  34. data/app/views/completion_kit/runs/_sort_toolbar.html.erb +5 -4
  35. data/app/views/completion_kit/runs/_status_header.html.erb +7 -31
  36. data/app/views/completion_kit/runs/_status_panel.html.erb +80 -0
  37. data/app/views/completion_kit/runs/index.html.erb +4 -16
  38. data/app/views/completion_kit/runs/show.html.erb +111 -17
  39. data/app/views/completion_kit/suggestions/show.html.erb +65 -0
  40. data/app/views/layouts/completion_kit/application.html.erb +71 -0
  41. data/config/routes.rb +8 -2
  42. data/db/migrate/20260507000001_add_discovery_error_to_provider_credentials.rb +5 -0
  43. data/db/migrate/20260507150000_add_temperature_ignored_to_runs.rb +5 -0
  44. data/lib/completion_kit/version.rb +1 -1
  45. metadata +9 -4
  46. data/app/views/completion_kit/runs/_progress.html.erb +0 -18
  47. data/app/views/completion_kit/runs/suggestion.html.erb +0 -47
@@ -4,6 +4,8 @@ require "json"
4
4
 
5
5
  module CompletionKit
6
6
  class ModelDiscoveryService
7
+ class DiscoveryError < StandardError; end
8
+
7
9
  def initialize(config:)
8
10
  @provider = config[:provider]
9
11
  @api_key = config[:api_key]
@@ -13,7 +15,6 @@ module CompletionKit
13
15
  def refresh!(&on_progress)
14
16
  models_with_names = fetch_models
15
17
  reconcile(models_with_names)
16
- return if %w[openrouter ollama].include?(@provider)
17
18
  probe_new_models(&on_progress)
18
19
  end
19
20
 
@@ -37,11 +38,31 @@ module CompletionKit
37
38
  end
38
39
  end
39
40
 
41
+ def raise_fetch_error!(response)
42
+ label = case response.status
43
+ when 401, 403 then "Invalid API key for #{@provider}"
44
+ when 429 then "Rate limited by #{@provider}"
45
+ when 500..599 then "#{@provider} returned #{response.status}"
46
+ else "#{@provider} model list request failed (#{response.status})"
47
+ end
48
+ detail = extract_provider_error_message(response.body)
49
+ raise DiscoveryError, detail.present? ? "#{label}: #{detail}" : label
50
+ end
51
+
52
+ def extract_provider_error_message(body)
53
+ return nil if body.blank?
54
+ data = JSON.parse(body)
55
+ err = data["error"]
56
+ (err.is_a?(Hash) && err["message"]) || data["message"] || (err.is_a?(String) && err) || nil
57
+ rescue JSON::ParserError
58
+ body.to_s.truncate(200)
59
+ end
60
+
40
61
  def fetch_openai_models
41
62
  response = fetch_connection("https://api.openai.com").get("/v1/models") do |req|
42
63
  req.headers["Authorization"] = "Bearer #{@api_key}"
43
64
  end
44
- return [] unless response.success?
65
+ raise_fetch_error!(response) unless response.success?
45
66
  JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: nil } }
46
67
  end
47
68
 
@@ -50,7 +71,7 @@ module CompletionKit
50
71
  req.headers["x-api-key"] = @api_key
51
72
  req.headers["anthropic-version"] = "2023-06-01"
52
73
  end
53
- return [] unless response.success?
74
+ raise_fetch_error!(response) unless response.success?
54
75
  JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["display_name"] } }
55
76
  end
56
77
 
@@ -60,7 +81,7 @@ module CompletionKit
60
81
  req.headers["HTTP-Referer"] = "https://completionkit.com"
61
82
  req.headers["X-Title"] = "CompletionKit"
62
83
  end
63
- return [] unless response.success?
84
+ raise_fetch_error!(response) unless response.success?
64
85
  JSON.parse(response.body).fetch("data", []).filter_map do |entry|
65
86
  next nil if entry["deprecated"] == true
66
87
  context_length = entry["context_length"].to_i
@@ -70,12 +91,12 @@ module CompletionKit
70
91
  end
71
92
 
72
93
  def fetch_ollama_models
73
- return [] if @api_endpoint.nil?
94
+ raise DiscoveryError, "Ollama endpoint URL is required" if @api_endpoint.blank?
74
95
  base_url = @api_endpoint.to_s.delete_suffix("/")
75
96
  response = fetch_connection(base_url).get("/models") do |req|
76
97
  req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
77
98
  end
78
- return [] unless response.success?
99
+ raise_fetch_error!(response) unless response.success?
79
100
  JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["id"] } }
80
101
  end
81
102
 
@@ -111,29 +132,55 @@ module CompletionKit
111
132
  end
112
133
 
113
134
  def probe_new_models(&on_progress)
114
- unprobed = Model.where(provider: @provider, supports_generation: nil, status: "active")
115
- total = unprobed.count
135
+ candidates = Model.where(provider: @provider, status: %w[active failed])
136
+ .where("supports_generation IS NULL OR supports_judging IS NULL OR (generation_error IS NOT NULL AND #{retryable_error_sql('generation_error')}) OR (judging_error IS NOT NULL AND #{retryable_error_sql('judging_error')})")
137
+ total = candidates.count
116
138
  current = 0
117
- unprobed.find_each do |model|
118
- probe_generation(model)
119
- probe_judging(model) if model.supports_generation
120
- model.probed_at = Time.current
121
- model.status = "failed" if model.supports_generation == false
122
- model.save!
139
+ candidates.find_each do |model|
140
+ probed = false
141
+ if model.supports_generation.nil? || retryable_error?(model.generation_error)
142
+ model.generation_error = nil
143
+ probe_generation(model)
144
+ probed = true
145
+ end
146
+ if model.supports_generation && (model.supports_judging.nil? || retryable_error?(model.judging_error))
147
+ model.judging_error = nil
148
+ probe_judging(model)
149
+ probed = true
150
+ end
151
+ if probed
152
+ model.probed_at = Time.current
153
+ model.status = (model.supports_generation == false ? "failed" : "active")
154
+ model.save!
155
+ end
123
156
  current += 1
124
157
  on_progress&.call(current, total)
125
158
  end
126
159
  end
127
160
 
161
+ def retryable_error?(error_string)
162
+ return false if error_string.blank?
163
+ return true if error_string.start_with?("429 ")
164
+ !error_string.match?(/\A4\d\d\s/)
165
+ end
166
+
167
+ def retryable_error_sql(column)
168
+ "(#{column} LIKE '429 -%' OR #{column} NOT LIKE '4__ -%')"
169
+ end
170
+
128
171
  def probe_generation(model)
129
- response = send_probe(model.model_id, "Say hello", 20)
172
+ probe_input = "Reply with exactly this token and nothing else: PING-OK"
173
+ response = send_probe(model.model_id, probe_input, 65536)
130
174
  if response.success?
131
- text = extract_text(response)
132
- if text.present?
175
+ text = extract_text(response).to_s
176
+ if text.blank?
177
+ model.supports_generation = false
178
+ model.generation_error = "Empty response"
179
+ elsif text.include?("PING-OK")
133
180
  model.supports_generation = true
134
181
  else
135
182
  model.supports_generation = false
136
- model.generation_error = "Empty response"
183
+ model.generation_error = "Did not follow text completion instruction (likely non-text-output model): #{text.truncate(200)}"
137
184
  end
138
185
  else
139
186
  model.supports_generation = false
@@ -154,7 +201,7 @@ module CompletionKit
154
201
  AI output to evaluate: The sky is blue.
155
202
  PROMPT
156
203
 
157
- response = send_probe(model.model_id, judge_input, 50)
204
+ response = send_probe(model.model_id, judge_input, 65536)
158
205
  if response.success?
159
206
  text = extract_text(response).to_s
160
207
  if text.match?(/Score:\s*\d/i)
@@ -173,37 +220,60 @@ module CompletionKit
173
220
  end
174
221
 
175
222
  def send_probe(model_id, input, max_tokens)
176
- if @provider == "openai"
177
- openai_probe(model_id, input, max_tokens)
178
- else
179
- anthropic_probe(model_id, input, max_tokens)
223
+ case @provider
224
+ when "openai" then openai_probe(model_id, input, max_tokens)
225
+ when "anthropic" then anthropic_probe(model_id, input, max_tokens)
226
+ when "openrouter" then openrouter_probe(model_id, input, max_tokens)
227
+ when "ollama" then ollama_probe(model_id, input, max_tokens)
228
+ else raise ArgumentError, "Unsupported probe provider: #{@provider}"
180
229
  end
181
230
  end
182
231
 
183
232
  def extract_text(response)
184
233
  data = JSON.parse(response.body)
185
- if @provider == "openai"
186
- data.dig("output", 0, "content", 0, "text")
187
- else
188
- data.dig("content", 0, "text")
234
+ case @provider
235
+ when "openai"
236
+ message = Array(data["output"]).find { |o| o["type"] == "message" }
237
+ message&.dig("content", 0, "text")
238
+ when "anthropic" then data.dig("content", 0, "text")
239
+ else data.dig("choices", 0, "message", "content")
189
240
  end
190
241
  end
191
242
 
192
243
  def openai_probe(model_id, input, max_tokens)
193
- conn = Faraday.new(url: "https://api.openai.com") do |f|
194
- f.options.timeout = 15
244
+ conn = openai_probe_connection
245
+ response = openai_probe_post(conn, model_id, input, max_tokens, openai_reasoning_effort_for(model_id))
246
+ if response.status == 400 && response.body.to_s.include?("is not supported with the")
247
+ response = openai_probe_post(conn, model_id, input, max_tokens, nil)
248
+ end
249
+ response
250
+ end
251
+
252
+ def openai_probe_connection
253
+ Faraday.new(url: "https://api.openai.com") do |f|
254
+ f.options.timeout = 180
195
255
  f.options.open_timeout = 5
196
256
  f.request :retry, max: 1, interval: 0.5
197
257
  f.adapter Faraday.default_adapter
198
258
  end
259
+ end
260
+
261
+ def openai_probe_post(conn, model_id, input, max_tokens, effort)
262
+ body = { model: model_id, input: input, max_output_tokens: max_tokens, store: false }
263
+ body[:reasoning] = { effort: effort } if effort
199
264
  conn.post do |req|
200
265
  req.url "/v1/responses"
201
266
  req.headers["Content-Type"] = "application/json"
202
267
  req.headers["Authorization"] = "Bearer #{@api_key}"
203
- req.body = { model: model_id, input: input, max_output_tokens: max_tokens, store: false }.to_json
268
+ req.body = body.to_json
204
269
  end
205
270
  end
206
271
 
272
+ def openai_reasoning_effort_for(model_id)
273
+ return nil unless model_id.to_s.match?(/\A(gpt-5|o1|o3)/)
274
+ "low"
275
+ end
276
+
207
277
  def anthropic_probe(model_id, input, max_tokens)
208
278
  conn = Faraday.new(url: "https://api.anthropic.com") do |f|
209
279
  f.options.timeout = 15
@@ -219,5 +289,38 @@ module CompletionKit
219
289
  req.body = { model: model_id, messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
220
290
  end
221
291
  end
292
+
293
+ def openrouter_probe(model_id, input, max_tokens)
294
+ conn = Faraday.new(url: "https://openrouter.ai") do |f|
295
+ f.options.timeout = 30
296
+ f.options.open_timeout = 5
297
+ f.request :retry, max: 1, interval: 0.5
298
+ f.adapter Faraday.default_adapter
299
+ end
300
+ conn.post do |req|
301
+ req.url "/api/v1/chat/completions"
302
+ req.headers["Content-Type"] = "application/json"
303
+ req.headers["Authorization"] = "Bearer #{@api_key}"
304
+ req.headers["HTTP-Referer"] = "https://completionkit.com"
305
+ req.headers["X-Title"] = "CompletionKit"
306
+ req.body = { model: model_id, messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
307
+ end
308
+ end
309
+
310
+ def ollama_probe(model_id, input, max_tokens)
311
+ base_url = @api_endpoint.to_s.delete_suffix("/")
312
+ conn = Faraday.new(url: base_url) do |f|
313
+ f.options.timeout = 60
314
+ f.options.open_timeout = 5
315
+ f.request :retry, max: 1, interval: 0.5
316
+ f.adapter Faraday.default_adapter
317
+ end
318
+ conn.post do |req|
319
+ req.url "/chat/completions"
320
+ req.headers["Content-Type"] = "application/json"
321
+ req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
322
+ req.body = { model: model_id, messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
323
+ end
324
+ end
222
325
  end
223
326
  end
@@ -1,22 +1,22 @@
1
1
  module CompletionKit
2
2
  class OllamaClient < LlmClient
3
+ def temperature_dropped?
4
+ @temperature_dropped == true
5
+ end
6
+
3
7
  def generate_completion(prompt, options = {})
8
+ @temperature_dropped = false
4
9
  return "Error: API endpoint not configured" unless configured?
5
10
 
6
11
  model = options[:model]
7
12
  max_tokens = options[:max_tokens] || 1000
8
13
  temperature = options[:temperature] || 0.7
9
14
 
10
- response = build_connection(api_endpoint).post do |req|
11
- req.url "/v1/completions"
12
- req.headers["Content-Type"] = "application/json"
13
- req.headers["Authorization"] = "Bearer #{api_key}" if api_key.present?
14
- req.body = {
15
- model: model,
16
- prompt: prompt,
17
- max_tokens: max_tokens,
18
- temperature: temperature
19
- }.to_json
15
+ response = post_completion(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
16
+
17
+ if response.status == 400 && temperature_unsupported?(response.body)
18
+ @temperature_dropped = true
19
+ response = post_completion(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
20
20
  end
21
21
 
22
22
  if response.status == 429
@@ -76,5 +76,26 @@ module CompletionKit
76
76
  def api_endpoint
77
77
  (@config[:api_endpoint] || ENV["OLLAMA_API_ENDPOINT"] || "http://localhost:11434/v1").to_s.delete_suffix("/")
78
78
  end
79
+
80
+ def post_completion(model:, prompt:, max_tokens:, temperature:)
81
+ body = {
82
+ model: model,
83
+ prompt: prompt,
84
+ max_tokens: max_tokens
85
+ }
86
+ body[:temperature] = temperature unless temperature.nil?
87
+
88
+ build_connection(api_endpoint).post do |req|
89
+ req.url "/v1/completions"
90
+ req.headers["Content-Type"] = "application/json"
91
+ req.headers["Authorization"] = "Bearer #{api_key}" if api_key.present?
92
+ req.body = body.to_json
93
+ end
94
+ end
95
+
96
+ def temperature_unsupported?(body)
97
+ s = body.to_s
98
+ s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported") || s.include?("Unsupported parameter"))
99
+ end
79
100
  end
80
101
  end
@@ -6,25 +6,23 @@ module CompletionKit
6
6
  { id: "gpt-4o-mini", name: "GPT-4o Mini" }
7
7
  ].freeze
8
8
 
9
+ def temperature_dropped?
10
+ @temperature_dropped == true
11
+ end
12
+
9
13
  def generate_completion(prompt, options = {})
14
+ @temperature_dropped = false
10
15
  return "Error: API key not configured" unless configured?
11
16
 
12
17
  model = options[:model] || "gpt-4.1-mini"
13
18
  max_tokens = options[:max_tokens] || 1000
14
19
  temperature = options[:temperature] || 0.7
15
20
 
16
- response = build_connection("https://api.openai.com").post do |req|
17
- req.url "/v1/responses"
18
- req.headers["Content-Type"] = "application/json"
19
- req.headers["Authorization"] = "Bearer #{api_key}"
20
- req.body = {
21
- model: model,
22
- input: prompt,
23
- instructions: "You are a helpful assistant.",
24
- max_output_tokens: max_tokens,
25
- temperature: temperature,
26
- store: false
27
- }.to_json
21
+ response = post_responses(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
22
+
23
+ if response.status == 400 && temperature_unsupported?(response.body)
24
+ @temperature_dropped = true
25
+ response = post_responses(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
28
26
  end
29
27
 
30
28
  if response.status == 429
@@ -38,7 +36,8 @@ module CompletionKit
38
36
 
39
37
  if response.success?
40
38
  data = JSON.parse(response.body)
41
- data["output"][0]["content"][0]["text"].strip
39
+ message = Array(data["output"]).find { |o| o["type"] == "message" }
40
+ message&.dig("content", 0, "text").to_s.strip
42
41
  else
43
42
  "Error: #{response.status} - #{response.body}"
44
43
  end
@@ -69,5 +68,28 @@ module CompletionKit
69
68
  def api_key
70
69
  @config[:api_key] || ENV["OPENAI_API_KEY"]
71
70
  end
71
+
72
+ def post_responses(model:, prompt:, max_tokens:, temperature:)
73
+ body = {
74
+ model: model,
75
+ input: prompt,
76
+ instructions: "You are a helpful assistant.",
77
+ max_output_tokens: max_tokens,
78
+ store: false
79
+ }
80
+ body[:temperature] = temperature unless temperature.nil?
81
+
82
+ build_connection("https://api.openai.com").post do |req|
83
+ req.url "/v1/responses"
84
+ req.headers["Content-Type"] = "application/json"
85
+ req.headers["Authorization"] = "Bearer #{api_key}"
86
+ req.body = body.to_json
87
+ end
88
+ end
89
+
90
+ def temperature_unsupported?(body)
91
+ s = body.to_s
92
+ s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported") || s.include?("Unsupported parameter"))
93
+ end
72
94
  end
73
95
  end
@@ -1,28 +1,26 @@
1
1
  module CompletionKit
2
2
  class OpenRouterClient < LlmClient
3
- BASE_URL = "https://openrouter.ai/api/v1".freeze
3
+ BASE_URL = "https://openrouter.ai".freeze
4
4
  REFERER = "https://completionkit.com".freeze
5
5
  APP_TITLE = "CompletionKit".freeze
6
6
 
7
+ def temperature_dropped?
8
+ @temperature_dropped == true
9
+ end
10
+
7
11
  def generate_completion(prompt, options = {})
12
+ @temperature_dropped = false
8
13
  return "Error: API key not configured" unless configured?
9
14
 
10
15
  model = options[:model] || "openai/gpt-4o-mini"
11
16
  max_tokens = options[:max_tokens] || 1000
12
17
  temperature = options[:temperature] || 0.7
13
18
 
14
- response = build_connection(BASE_URL, timeout: 30, open_timeout: 5).post do |req|
15
- req.url "/chat/completions"
16
- req.headers["Content-Type"] = "application/json"
17
- req.headers["Authorization"] = "Bearer #{api_key}"
18
- req.headers["HTTP-Referer"] = REFERER
19
- req.headers["X-Title"] = APP_TITLE
20
- req.body = {
21
- model: model,
22
- messages: [{ role: "user", content: prompt }],
23
- max_tokens: max_tokens,
24
- temperature: temperature
25
- }.to_json
19
+ response = post_chat(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
20
+
21
+ if response.status == 400 && temperature_unsupported?(response.body)
22
+ @temperature_dropped = true
23
+ response = post_chat(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
26
24
  end
27
25
 
28
26
  if response.status == 429
@@ -67,5 +65,28 @@ module CompletionKit
67
65
  def api_key
68
66
  @config[:api_key] || ENV["OPENROUTER_API_KEY"]
69
67
  end
68
+
69
+ def post_chat(model:, prompt:, max_tokens:, temperature:)
70
+ body = {
71
+ model: model,
72
+ messages: [{ role: "user", content: prompt }],
73
+ max_tokens: max_tokens
74
+ }
75
+ body[:temperature] = temperature unless temperature.nil?
76
+
77
+ build_connection(BASE_URL, timeout: 30, open_timeout: 5).post do |req|
78
+ req.url "/api/v1/chat/completions"
79
+ req.headers["Content-Type"] = "application/json"
80
+ req.headers["Authorization"] = "Bearer #{api_key}"
81
+ req.headers["HTTP-Referer"] = REFERER
82
+ req.headers["X-Title"] = APP_TITLE
83
+ req.body = body.to_json
84
+ end
85
+ end
86
+
87
+ def temperature_unsupported?(body)
88
+ s = body.to_s
89
+ s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported") || s.include?("Unsupported parameter"))
90
+ end
70
91
  end
71
92
  end
@@ -4,7 +4,10 @@ module CompletionKit
4
4
 
5
5
  def self.healthy?
6
6
  return true unless defined?(::SolidQueue::Process)
7
- ::SolidQueue::Process.where("last_heartbeat_at > ?", HEARTBEAT_THRESHOLD.ago).exists?
7
+ ::SolidQueue::Process
8
+ .where("kind LIKE 'Worker%'")
9
+ .where("last_heartbeat_at > ?", HEARTBEAT_THRESHOLD.ago)
10
+ .exists?
8
11
  end
9
12
  end
10
13
  end
@@ -25,7 +25,7 @@
25
25
  <td><strong><%= dataset.name %></strong></td>
26
26
  <td><%= dataset.row_count %></td>
27
27
  <td><%= dataset.runs.count %></td>
28
- <td><%= dataset.created_at.strftime("%Y-%m-%d") %></td>
28
+ <td class="ck-meta-copy"><time datetime="<%= dataset.created_at.iso8601 %>"><%= dataset.created_at.strftime("%b %-d, %Y") %></time></td>
29
29
  <td class="ck-results-table__arrow">&rarr;</td>
30
30
  </tr>
31
31
  <% end %>
@@ -14,30 +14,68 @@
14
14
 
15
15
  <section>
16
16
  <p class="ck-kicker">CSV preview</p>
17
- <pre class="ck-code ck-code--dark"><%= @dataset.csv_data %></pre>
17
+ <%
18
+ require "csv"
19
+ parsed_rows = []
20
+ parse_error = nil
21
+ begin
22
+ csv = ::CSV.parse(@dataset.csv_data.to_s)
23
+ parsed_rows = csv
24
+ rescue ::CSV::MalformedCSVError => e
25
+ parse_error = e.message
26
+ end
27
+ headers = parsed_rows.first || []
28
+ body_rows = parsed_rows.drop(1)
29
+ %>
30
+ <% if parse_error %>
31
+ <p class="ck-field-hint" style="color: var(--ck-warning);">Could not parse CSV: <%= parse_error %></p>
32
+ <pre class="ck-code ck-code--dark"><%= @dataset.csv_data %></pre>
33
+ <% elsif headers.empty? %>
34
+ <p class="ck-field-hint">Dataset is empty.</p>
35
+ <% else %>
36
+ <div class="ck-csv-table-wrap">
37
+ <table class="ck-csv-table">
38
+ <thead>
39
+ <tr>
40
+ <th class="ck-csv-table__rownum">#</th>
41
+ <% headers.each do |h| %>
42
+ <th><%= h %></th>
43
+ <% end %>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ <% body_rows.each_with_index do |row, idx| %>
48
+ <tr>
49
+ <td class="ck-csv-table__rownum"><%= idx + 1 %></td>
50
+ <% headers.each_with_index do |_, i| %>
51
+ <td><span class="ck-csv-cell"><%= row[i] %></span></td>
52
+ <% end %>
53
+ </tr>
54
+ <% end %>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ <% end %>
18
59
  </section>
19
60
 
20
61
  <% if @runs.any? %>
21
62
  <section class="ck-card--spaced">
22
63
  <p class="ck-kicker">Runs</p>
23
64
 
24
- <table class="ck-results-table" style="margin-top: 0.5rem;">
65
+ <table class="ck-results-table ck-runs-table" style="margin-top: 0.5rem;">
25
66
  <thead>
26
67
  <tr>
27
68
  <th>Run</th>
28
- <th>Prompt</th>
29
69
  <th>Responses</th>
70
+ <th>Metrics</th>
71
+ <th>Avg score</th>
72
+ <th>When</th>
30
73
  <th></th>
31
74
  </tr>
32
75
  </thead>
33
76
  <tbody>
34
77
  <% @runs.each do |run| %>
35
- <tr onclick="window.location='<%= run_path(run) %>'" style="cursor: pointer;">
36
- <td><strong><%= run.name %></strong></td>
37
- <td><%= link_to run.prompt.name, prompt_path(run.prompt), class: "ck-link" %></td>
38
- <td><%= run.responses.size %></td>
39
- <td class="ck-results-table__arrow">&rarr;</td>
40
- </tr>
78
+ <%= render "completion_kit/runs/row", run: run %>
41
79
  <% end %>
42
80
  </tbody>
43
81
  </table>
@@ -31,7 +31,7 @@
31
31
  <div class="ck-rubric-row">
32
32
  <div class="ck-rubric-row__stars">
33
33
  <% 5.times do |i| %>
34
- <svg viewBox="0 0 24 24" width="14" height="14" stroke-width="1.75" class="ck-star <%= i < band["stars"] ? "ck-star--filled" : "ck-star--empty" %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
34
+ <svg viewBox="0 0 24 24" width="16" height="16" stroke-width="1.75" class="ck-star <%= i < band["stars"] ? "ck-star--filled" : "ck-star--empty" %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
35
35
  <% end %>
36
36
  <input type="hidden" name="metric[rubric_bands][<%= index %>][stars]" value="<%= band["stars"] %>">
37
37
  </div>
@@ -9,7 +9,7 @@
9
9
  </section>
10
10
 
11
11
  <% if @metrics.any? %>
12
- <table class="ck-results-table">
12
+ <table class="ck-results-table ck-metrics-table">
13
13
  <thead>
14
14
  <tr>
15
15
  <th>Name</th>
@@ -23,7 +23,20 @@
23
23
  <tr onclick="window.location='<%= metric_path(metric) %>'" style="cursor: pointer;">
24
24
  <td><strong><%= metric.name %></strong></td>
25
25
  <td class="ck-meta-copy"><%= truncate(metric.instruction.to_s, length: 90).presence || "—" %></td>
26
- <td class="ck-meta-copy"><%= metric.metric_groups.any? ? metric.metric_groups.map(&:name).join(", ") : "—" %></td>
26
+ <td>
27
+ <% groups = metric.metric_groups %>
28
+ <% if groups.any? %>
29
+ <div class="ck-metrics-table__groups">
30
+ <% groups.each do |g| %>
31
+ <%= link_to metric_group_path(g), class: "ck-metric-group-pill ck-metric-group-pill--active", onclick: "event.stopPropagation();" do %>
32
+ <span class="ck-metric-group-pill__label"><%= g.name %></span>
33
+ <% end %>
34
+ <% end %>
35
+ </div>
36
+ <% else %>
37
+ <span class="ck-metrics-table__dim">—</span>
38
+ <% end %>
39
+ </td>
27
40
  <td class="ck-results-table__arrow">&rarr;</td>
28
41
  </tr>
29
42
  <% end %>
@@ -26,7 +26,7 @@
26
26
  <div class="ck-rubric-row ck-rubric-row--display">
27
27
  <div class="ck-rubric-row__stars">
28
28
  <% 5.times do |i| %>
29
- <svg viewBox="0 0 24 24" width="14" height="14" stroke-width="1.75" class="ck-star <%= i < band["stars"] ? "ck-star--filled" : "ck-star--empty" %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
29
+ <svg viewBox="0 0 24 24" width="16" height="16" stroke-width="1.75" class="ck-star <%= i < band["stars"] ? "ck-star--filled" : "ck-star--empty" %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
30
30
  <% end %>
31
31
  </div>
32
32
  <div class="ck-rubric-row__fields">