completion-kit 0.29.0 → 0.29.1

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/README.md +48 -3
  3. data/app/assets/javascripts/completion_kit/application.js +383 -0
  4. data/app/controllers/completion_kit/mcp_controller.rb +4 -0
  5. data/app/models/completion_kit/dataset.rb +53 -4
  6. data/app/models/completion_kit/metric.rb +1 -1
  7. data/app/models/completion_kit/prompt.rb +8 -0
  8. data/app/models/completion_kit/run.rb +1 -1
  9. data/app/services/completion_kit/api_config.rb +2 -1
  10. data/app/services/completion_kit/checks/regex.rb +12 -2
  11. data/app/services/completion_kit/csv_processor.rb +14 -2
  12. data/app/services/completion_kit/model_discovery_service.rb +2 -1
  13. data/app/services/completion_kit/ollama_client.rb +19 -2
  14. data/app/services/completion_kit/provider_endpoint.rb +10 -0
  15. data/app/views/completion_kit/api_reference/_authentication.html.erb +2 -2
  16. data/app/views/completion_kit/api_reference/_body.html.erb +0 -27
  17. data/app/views/completion_kit/api_reference/_example.html.erb +1 -1
  18. data/app/views/completion_kit/api_reference/_resource_card.html.erb +1 -1
  19. data/app/views/completion_kit/datasets/_form.html.erb +0 -28
  20. data/app/views/completion_kit/datasets/index.html.erb +1 -1
  21. data/app/views/completion_kit/metric_groups/_form.html.erb +1 -20
  22. data/app/views/completion_kit/metric_groups/index.html.erb +1 -1
  23. data/app/views/completion_kit/metrics/_starter_card.html.erb +1 -1
  24. data/app/views/completion_kit/metrics/index.html.erb +2 -2
  25. data/app/views/completion_kit/metrics/show.html.erb +4 -14
  26. data/app/views/completion_kit/prompts/_form.html.erb +1 -1
  27. data/app/views/completion_kit/prompts/index.html.erb +3 -3
  28. data/app/views/completion_kit/prompts/show.html.erb +8 -8
  29. data/app/views/completion_kit/provider_credentials/_models_card.html.erb +2 -2
  30. data/app/views/completion_kit/runs/_form.html.erb +3 -179
  31. data/app/views/completion_kit/runs/_response_row.html.erb +2 -3
  32. data/app/views/completion_kit/runs/_row.html.erb +3 -3
  33. data/app/views/completion_kit/runs/_run_config.html.erb +1 -1
  34. data/app/views/completion_kit/runs/show.html.erb +5 -19
  35. data/app/views/completion_kit/tags/index.html.erb +1 -1
  36. data/app/views/layouts/completion_kit/application.html.erb +2 -2
  37. data/lib/completion_kit/version.rb +1 -1
  38. data/lib/completion_kit.rb +14 -0
  39. data/lib/generators/completion_kit/templates/initializer.rb +18 -0
  40. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1da0c4306c55de2ce239393e22ce0512a051e9c85285165fa363b746bbf4a09
4
- data.tar.gz: e9f2adb520737b42f5b9a51841cf32b0e5249bb74b5c13bedc3758c17dbca29a
3
+ metadata.gz: f314f2989362808e829e3f1bbbfc5718eca193aa4602ae4b4b3e72bc6c9251c1
4
+ data.tar.gz: b9322db963ff8bb00660e09309940f692cab0fff289c8069b3be68ecf81db429
5
5
  SHA512:
6
- metadata.gz: 226c925a5fa2e048acbb20d0c71bf63c02af14102073875d7c42858da7e9a36c6a6f4ad87eb570a5a18d977e25a16e104ec95e37f2a1424f1ddadd3a7a3e0c69
7
- data.tar.gz: 64638c834849310c7f818bd2db14bf0a682447e4b52409bd5f9656ec0fe14b732a08dea84d8c414adb36e509e807b7869945271aa6ee8aca8c7396c99a78c324
6
+ metadata.gz: bff1f44883f8a652ffde54da844bd626db029e432e91f5f79648b926b34ada993599b0a04176bfc842bd1c7da786dc9a05af5744779ba6e3fdca677e213639d1
7
+ data.tar.gz: bff0d10f9a3f7ccc857d52b5038ac06e4ccbc6e15e73195ff18c95a2f0223c4d223bdd17749f36d6933d7bb5925e3dd9e522cf4e012b26ac76a9c9b78c40a0c6
data/README.md CHANGED
@@ -71,10 +71,33 @@ The engine mounts at `/completion_kit`. Generate / judge flows enqueue Active Jo
71
71
 
72
72
  ```erb
73
73
  <%= stylesheet_link_tag "completion_kit/application", media: "all" %>
74
- <%= javascript_include_tag "completion_kit/application", defer: true %>
74
+ <%= javascript_include_tag "completion_kit/application", defer: true, nonce: true %>
75
75
  ```
76
76
 
77
- Without the JavaScript include, in-page behaviours silently fail: live tag-breadcrumb updates, relative-time ticking, CSV row hover-expand, model-refresh progress, focus-first-error, and local-time formatting.
77
+ Without the JavaScript include, in-page behaviours silently fail: clickable table rows, dialogs, copy buttons, the API token reveal, the run form, live run status, live tag-breadcrumb updates, relative-time ticking, CSV row hover-expand, model-refresh progress, focus-first-error, and local-time formatting.
78
+
79
+ **Content Security Policy.** The engine's pages have no inline event handlers or inline scripts, and the engine layout puts the request nonce on its script tags, so the pages work under a nonce-based `script-src`. The views still use inline `style` attributes, so leave `style-src` out of the nonce directives and allow `'unsafe-inline'` there for now.
80
+
81
+ Use a nonce that lasts the whole session, not a fresh random one per request. Turbo Drive re-creates the scripts in each new page body with the nonce from the `csp-nonce` meta tag, but the browser keeps enforcing the policy header of the first page it loaded, so a per-request nonce blocks nonce scripts after the first Drive visit.
82
+
83
+ Keep a random nonce in the session, and skip it for API controllers so cookie-less API and MCP calls get no session cookie, as the example below does. The Rails default, `->(request) { request.session.id.to_s }`, also lasts the session, but it is empty on a brand-new visitor's first response, so a policy that admits scripts only by nonce blocks that first page's scripts until the session exists.
84
+
85
+ ```ruby
86
+ Rails.application.configure do
87
+ config.content_security_policy do |policy|
88
+ policy.default_src :self
89
+ policy.script_src :self
90
+ policy.style_src :self, :unsafe_inline
91
+ end
92
+
93
+ config.content_security_policy_nonce_generator = lambda do |request|
94
+ next if request.controller_instance.is_a?(ActionController::API)
95
+
96
+ request.session[:csp_nonce] ||= SecureRandom.base64(16)
97
+ end
98
+ config.content_security_policy_nonce_directives = %w(script-src)
99
+ end
100
+ ```
78
101
 
79
102
  ## Providers
80
103
 
@@ -84,12 +107,14 @@ CompletionKit discovers available models from each provider's API automatically.
84
107
  |----------|----------|----------------|
85
108
  | **OpenAI** | `OPENAI_API_KEY` | GPT-5, GPT-4.1, GPT-4o, etc. |
86
109
  | **Anthropic** | `ANTHROPIC_API_KEY` | Claude Opus, Sonnet, Haiku |
87
- | **Ollama / local endpoint** | `OLLAMA_API_ENDPOINT` (default: `http://localhost:11434/v1`) | Any model you've `ollama pull`-ed, or any OpenAI-compatible local server (vLLM, LM Studio, llama.cpp) |
110
+ | **Ollama / local endpoint** | `OLLAMA_API_ENDPOINT` (default: `http://localhost:11434/v1`), `OLLAMA_API_KEY` (optional) | Any model you've `ollama pull`-ed, or any OpenAI-compatible local server (vLLM, LM Studio, llama.cpp) |
88
111
  | **OpenRouter** | `OPENROUTER_API_KEY` | 100+ models from 30+ providers through one API key |
89
112
  | **Azure AI Foundry** | No env var — add via the Providers UI / a `ProviderCredential` (`api_key` + `api_endpoint` required, `api_version` optional) | Azure OpenAI deployments and Foundry projects |
90
113
 
91
114
  Set these as environment variables or configure them in the generated initializer. You can also add provider credentials through the web UI under Settings.
92
115
 
116
+ The Ollama key from `OLLAMA_API_KEY` or `config.ollama_api_key` goes only to the Ollama endpoint from `OLLAMA_API_ENDPOINT` or `config.ollama_api_endpoint`, or to `http://localhost:11434/v1` when neither is set and `config.allow_loopback_endpoints` is on, which is the default. A provider credential with no key of its own gets that key only while it points at that same endpoint. Point it anywhere else and CompletionKit sends no key, so store the key on the credential itself. A key stored on a credential always goes to that credential's endpoint. Two endpoints match when their scheme, host, port and path match. The case of the scheme and host, a default port written out, a trailing slash and a trailing `/v1` make no difference. A blank stored key counts as no key.
117
+
93
118
  ### Encryption
94
119
 
95
120
  Provider API keys are encrypted at rest using [Active Record encryption](https://guides.rubyonrails.org/active_record_encryption.html). You need three encryption keys configured before the app will boot in production.
@@ -169,6 +194,26 @@ end
169
194
 
170
195
  Limiting uses `Rails.cache`. A shared cache store (Solid Cache, Redis) throttles accurately across multiple app instances; a per-process store still throttles each instance independently.
171
196
 
197
+ ## Input limits
198
+
199
+ Uploads, prompt templates, and regex check patterns have limits, so one large or hostile input cannot tie up a worker. Tune them in the initializer:
200
+
201
+ ```ruby
202
+ CompletionKit.configure do |c|
203
+ c.max_upload_bytes = 25 * 1024 * 1024 # largest dataset CSV or import file (default 25 MB)
204
+ c.max_template_bytes = 1024 * 1024 # largest prompt template (default 1 MB)
205
+ c.regex_timeout = 1.0 # seconds a regex check may spend matching one row (default 1.0)
206
+ end
207
+ ```
208
+
209
+ A prompt whose template is over `max_template_bytes` fails validation when its template is saved. Prompts saved before a lower limit keep working until their template changes.
210
+
211
+ The first line of a dataset's CSV is its header row. A dataset fails validation when its CSV is saved with a header row that is blank or longer than 64 KB. The 64 KB limit is fixed, not a setting.
212
+
213
+ A dataset also fails validation when its CSV is saved with bytes that are not valid UTF-8, so export spreadsheets as UTF-8 CSV.
214
+
215
+ A regex check that runs past its time limit fails that row and says it timed out. The limit is `regex_timeout`, or your app's `Regexp.timeout` when that is shorter, so the engine never loosens a stricter limit your app set. The engine sets the limit on each pattern and never changes the process-wide `Regexp.timeout`. Set `regex_timeout` to a finite number of seconds of at least `0.001`, or to `nil` to use only `Regexp.timeout`. Any other value, such as `0` or `Float::INFINITY`, raises an `ArgumentError` when the initializer runs.
216
+
172
217
  ## How it works
173
218
 
174
219
  <p align="center">
@@ -483,3 +483,386 @@ function ckInstallConfirm() {
483
483
  }
484
484
  document.addEventListener("turbo:load", ckInstallConfirm);
485
485
  ckInstallConfirm();
486
+
487
+ var ckRowIgnoreSelector = "a, button, input, select, textarea, label, summary, form, dialog, [data-ck-row-ignore]";
488
+
489
+ function ckRowHref(e) {
490
+ var target = e.target;
491
+ if (!target || !target.closest) return null;
492
+ var row = target.closest("[data-ck-href]");
493
+ if (!row) return null;
494
+ var inner = target.closest(ckRowIgnoreSelector);
495
+ if (inner && row.contains(inner)) return null;
496
+ return row.getAttribute("data-ck-href");
497
+ }
498
+
499
+ document.addEventListener("click", function(e) {
500
+ var href = ckRowHref(e);
501
+ if (!href) return;
502
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
503
+ window.open(href, "_blank");
504
+ } else if (window.Turbo && typeof window.Turbo.visit === "function") {
505
+ window.Turbo.visit(href);
506
+ } else {
507
+ window.location.assign(href);
508
+ }
509
+ });
510
+
511
+ document.addEventListener("auxclick", function(e) {
512
+ if (e.button !== 1) return;
513
+ var href = ckRowHref(e);
514
+ if (href) window.open(href, "_blank");
515
+ });
516
+
517
+ document.addEventListener("click", function(e) {
518
+ if (e.target && e.target.closest && e.target.closest("[data-ck-stop-propagation]")) e.stopPropagation();
519
+ });
520
+
521
+ function ckShowDialog(dialog) {
522
+ if (dialog && typeof dialog.showModal === "function" && !dialog.open) dialog.showModal();
523
+ }
524
+
525
+ document.addEventListener("click", function(e) {
526
+ var target = e.target;
527
+ if (!target || !target.closest) return;
528
+ var opener = target.closest("[data-ck-dialog-open]");
529
+ if (opener) {
530
+ ckShowDialog(document.getElementById(opener.getAttribute("data-ck-dialog-open")));
531
+ return;
532
+ }
533
+ var closer = target.closest("[data-ck-dialog-close]");
534
+ if (closer) {
535
+ var dialog = closer.closest("dialog");
536
+ if (dialog) dialog.close();
537
+ return;
538
+ }
539
+ if (target.matches("dialog[data-ck-dialog]")) target.close();
540
+ });
541
+
542
+ document.addEventListener("turbo:load", function() {
543
+ ckShowDialog(document.querySelector("dialog[data-ck-dialog-autoopen]"));
544
+ });
545
+
546
+ function ckFlashCopied(el) {
547
+ el.classList.add("ck-api-copy--done");
548
+ setTimeout(function() { el.classList.remove("ck-api-copy--done"); }, 1500);
549
+ }
550
+
551
+ function ckCopyText(el, text) {
552
+ navigator.clipboard.writeText(text).then(function() {
553
+ if (el.hasAttribute("data-ck-copy-feedback")) ckFlashCopied(el);
554
+ });
555
+ }
556
+
557
+ function ckToggleToken(el) {
558
+ var showing = el.textContent === el.dataset.masked;
559
+ el.textContent = showing ? el.dataset.real : el.dataset.masked;
560
+ el.setAttribute("aria-pressed", showing ? "true" : "false");
561
+ el.setAttribute("aria-label", showing ? "Hide API token" : "Reveal API token");
562
+ navigator.clipboard.writeText(el.dataset.real).then(function() { ckFlashCopied(el); });
563
+ }
564
+
565
+ function ckCopyExample(btn) {
566
+ var text = btn.closest(".ck-api-example").querySelector("pre").textContent;
567
+ var realToken = btn.dataset.realToken;
568
+ var displayToken = btn.dataset.displayToken;
569
+ if (realToken && displayToken) text = text.split(displayToken).join(realToken);
570
+ navigator.clipboard.writeText(text).then(function() { ckFlashCopied(btn); });
571
+ }
572
+
573
+ document.addEventListener("click", function(e) {
574
+ var target = e.target;
575
+ if (!target || !target.closest) return;
576
+ var toggle = target.closest("[data-ck-token-toggle]");
577
+ if (toggle) { ckToggleToken(toggle); return; }
578
+ var example = target.closest("[data-ck-copy-example]");
579
+ if (example) { ckCopyExample(example); return; }
580
+ var literal = target.closest("[data-ck-copy-text]");
581
+ if (literal) { ckCopyText(literal, literal.getAttribute("data-ck-copy-text")); return; }
582
+ var copier = target.closest("[data-ck-copy]");
583
+ if (!copier) return;
584
+ var source = document.getElementById(copier.getAttribute("data-ck-copy"));
585
+ if (source) ckCopyText(copier, source.textContent);
586
+ });
587
+
588
+ document.addEventListener("click", function(e) {
589
+ var target = e.target;
590
+ if (!target || !target.closest) return;
591
+ if (target.closest("[data-ck-refresh-models]")) {
592
+ ckRefreshModels();
593
+ return;
594
+ }
595
+ var refresher = target.closest("[data-ck-provider-refresh]");
596
+ if (!refresher) return;
597
+ e.preventDefault();
598
+ fetch(refresher.getAttribute("data-ck-provider-refresh"), {
599
+ method: "POST",
600
+ headers: { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content") }
601
+ });
602
+ ckStartDiscoveryPolling(8000);
603
+ });
604
+
605
+ document.addEventListener("click", function(e) {
606
+ var toggle = e.target && e.target.closest && e.target.closest("[data-ck-prompt-toggle]");
607
+ if (!toggle) return;
608
+ var text = document.getElementById(toggle.getAttribute("aria-controls"));
609
+ if (!text) return;
610
+ var expanded = text.classList.toggle("ck-prompt-preview__text--expanded");
611
+ toggle.firstChild.textContent = expanded ? "Show less" : "Show more";
612
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
613
+ });
614
+
615
+ function ckToggleMetricGroupFilter(btn) {
616
+ btn.classList.toggle("tag-mark--off");
617
+ var bar = btn.closest("[data-metric-tag-filter]");
618
+ var grid = bar && bar.parentElement.querySelector("[data-metric-checkboxes]");
619
+ if (!grid) return;
620
+ var active = Array.prototype.map.call(
621
+ bar.querySelectorAll(".ck-metric-tag-filter__chip:not(.tag-mark--off)"),
622
+ function(b) { return b.getAttribute("data-filter-tag"); }
623
+ );
624
+ grid.querySelectorAll(".ck-checkbox-label").forEach(function(lbl) {
625
+ var tags = (lbl.getAttribute("data-metric-tags") || "").split(",").filter(Boolean);
626
+ var visible = active.length === 0 || tags.some(function(t) { return active.indexOf(t) !== -1; });
627
+ lbl.style.display = visible ? "" : "none";
628
+ });
629
+ }
630
+
631
+ document.addEventListener("click", function(e) {
632
+ var target = e.target;
633
+ if (!target || !target.closest) return;
634
+ var chip = target.closest("[data-metric-tag-filter] [data-filter-tag]");
635
+ if (chip) {
636
+ ckToggleMetricGroupFilter(chip);
637
+ return;
638
+ }
639
+ var group = target.closest("[data-metric-group]");
640
+ if (group) ckToggleMetricGroup(group);
641
+ });
642
+
643
+ function ckUpdateRunForm() {
644
+ var promptEl = document.getElementById('run_prompt_id');
645
+ var judgeEl = document.getElementById('run_judge_model');
646
+ var judgeOnlyEl = document.getElementById('run_judge_only');
647
+ var judgeOnly = !!(judgeOnlyEl && judgeOnlyEl.checked);
648
+ var promptField = document.getElementById('prompt-field');
649
+ var outputColumnField = document.getElementById('output-column-field');
650
+ var outputColumnEl = document.getElementById('run_output_column');
651
+ if (promptField) promptField.hidden = judgeOnly;
652
+ if (outputColumnField) outputColumnField.hidden = !judgeOnly;
653
+ if (judgeOnly && promptEl) promptEl.value = '';
654
+
655
+ var prompt = promptEl ? promptEl.value : '';
656
+ var judge = judgeEl ? judgeEl.value : '';
657
+ var judgeListed = !!judgeEl && judgeEl.type !== 'hidden';
658
+ var metrics = document.querySelectorAll('input[name="run[metric_ids][]"]:checked');
659
+ var judgeField = document.getElementById('judge-field');
660
+ var metricsField = document.getElementById('metrics-field');
661
+ var judgeHint = document.getElementById('judge-hint');
662
+ var metricsHint = document.getElementById('metrics-hint');
663
+ var submitBtn = document.getElementById('run-submit');
664
+
665
+ if (judgeField) judgeField.className = 'ck-field';
666
+ if (metricsField) metricsField.className = 'ck-field';
667
+ if (judgeHint) judgeHint.textContent = '';
668
+ if (metricsHint) metricsHint.textContent = '';
669
+
670
+ var judgedMetrics = Array.prototype.filter.call(metrics, function(cb) {
671
+ var lbl = cb.closest('[data-needs-judge]');
672
+ return !!lbl && lbl.dataset.needsJudge === '1';
673
+ });
674
+ var judgeUnpaired = judgeListed && !!judge && metrics.length === 0;
675
+ var metricsUnpaired = judgeListed && !judge && judgedMetrics.length > 0;
676
+ if (judgeListed && judge && metrics.length > 0 && judgedMetrics.length === 0) {
677
+ if (judgeHint) judgeHint.textContent = 'Checks need no judge, so this run will not use it.';
678
+ } else if (judgeListed && judge && metrics.length > 0) {
679
+ if (judgeHint) judgeHint.textContent = judgeOnly ? 'Existing outputs will be judged automatically.' : 'Responses will be generated then judged automatically.';
680
+ } else if (judgeUnpaired) {
681
+ if (judgeField) judgeField.className = 'ck-field ck-field--info';
682
+ if (metricsField) metricsField.className = 'ck-field ck-field--info';
683
+ if (metricsHint) metricsHint.textContent = 'Select at least one metric or group to enable judging.';
684
+ } else if (metricsUnpaired) {
685
+ if (judgeField) judgeField.className = 'ck-field ck-field--info';
686
+ if (judgeHint) judgeHint.textContent = 'Select a judge model to enable judging.';
687
+ }
688
+
689
+ var datasetEl = document.getElementById('run_dataset_id');
690
+ var datasetHint = document.getElementById('dataset-hint');
691
+ var datasetField = document.getElementById('dataset-field');
692
+ var dataset = datasetEl ? datasetEl.value : '';
693
+ var selectedOption = promptEl ? promptEl.options[promptEl.selectedIndex] : null;
694
+ var hasVars = selectedOption && selectedOption.dataset.hasVariables === '1';
695
+ var promptVars = (selectedOption && selectedOption.dataset.variables ? selectedOption.dataset.variables.split(/,\s*/) : []).filter(Boolean);
696
+
697
+ var missingVars = [];
698
+ if (hasVars && dataset && datasetEl) {
699
+ var datasetOption = datasetEl.options[datasetEl.selectedIndex];
700
+ var headers = (datasetOption && datasetOption.dataset.headers ? datasetOption.dataset.headers.split(/,\s*/) : []).filter(Boolean);
701
+ missingVars = promptVars.filter(function(v) { return headers.indexOf(v) === -1; });
702
+ }
703
+
704
+ if (datasetField) datasetField.className = 'ck-field';
705
+ if (datasetHint) datasetHint.textContent = '';
706
+ if (missingVars.length > 0) {
707
+ if (datasetField) datasetField.className = 'ck-field ck-field--error';
708
+ if (datasetHint) datasetHint.textContent = 'Dataset is missing ' + (missingVars.length === 1 ? 'column' : 'columns') + ' the prompt needs: ' + missingVars.join(', ');
709
+ } else if (hasVars && !dataset) {
710
+ if (datasetField) datasetField.className = 'ck-field ck-field--info';
711
+ if (datasetHint) datasetHint.textContent = 'This prompt uses variables. Select a dataset to provide values.';
712
+ }
713
+
714
+ var expectedWarn = document.getElementById('expected-column-warn');
715
+ var expectedField = document.getElementById('expected-column-field');
716
+ var expectedColEl = document.getElementById('run_expected_column');
717
+ if (expectedField) expectedField.className = 'ck-field';
718
+ if (expectedWarn) expectedWarn.textContent = '';
719
+ var wantsExpected = false;
720
+ document.querySelectorAll('input[name="run[metric_ids][]"]:checked').forEach(function(cb) {
721
+ var lbl = cb.closest('[data-compare-expected]');
722
+ if (lbl && lbl.dataset.compareExpected === '1') wantsExpected = true;
723
+ });
724
+ if (wantsExpected && dataset && datasetEl) {
725
+ var expOption = datasetEl.options[datasetEl.selectedIndex];
726
+ var expHeaders = (expOption && expOption.dataset.headers ? expOption.dataset.headers.split(/,\s*/) : []).filter(Boolean);
727
+ var expCol = ((expectedColEl && expectedColEl.value) || 'expected_output').trim();
728
+ if (expHeaders.indexOf(expCol) === -1) {
729
+ if (expectedField) expectedField.className = 'ck-field ck-field--error';
730
+ if (expectedWarn) expectedWarn.textContent = "A selected check grades against each row's expected value, but this dataset has no \"" + expCol + "\" column, so every row would fail. Add the column or point this at the right one.";
731
+ }
732
+ }
733
+
734
+ var summary = document.getElementById('prompt-summary');
735
+ if (summary) {
736
+ if (selectedOption && selectedOption.value) {
737
+ var desc = selectedOption.dataset.description || '';
738
+ var tmpl = selectedOption.dataset.templatePreview || '';
739
+ var descEl = document.getElementById('prompt-summary-description');
740
+ descEl.textContent = desc;
741
+ descEl.hidden = !desc;
742
+ document.getElementById('prompt-summary-template').textContent = tmpl;
743
+ summary.hidden = false;
744
+ } else {
745
+ summary.hidden = true;
746
+ }
747
+ }
748
+
749
+ var valid;
750
+ if (judgeOnly) {
751
+ valid = !!dataset;
752
+ if (dataset && datasetEl && outputColumnEl) {
753
+ var headersJudge = (datasetEl.options[datasetEl.selectedIndex] && datasetEl.options[datasetEl.selectedIndex].dataset.headers ? datasetEl.options[datasetEl.selectedIndex].dataset.headers.split(/,\s*/) : []).filter(Boolean);
754
+ var col = (outputColumnEl.value || 'actual_output').trim();
755
+ if (col === '' || headersJudge.indexOf(col) === -1) {
756
+ valid = false;
757
+ if (datasetField) datasetField.className = 'ck-field ck-field--error';
758
+ if (datasetHint) datasetHint.textContent = "Dataset has no \"" + col + "\" column. Pick a different output column or dataset.";
759
+ }
760
+ } else if (!dataset) {
761
+ if (datasetField) datasetField.className = 'ck-field ck-field--info';
762
+ if (datasetHint) datasetHint.textContent = 'Skip generation and score responses you already have from a dataset column. Works with rubric metrics or deterministic checks.';
763
+ }
764
+ } else {
765
+ valid = prompt !== '';
766
+ if (hasVars && !dataset) valid = false;
767
+ if (missingVars.length > 0) valid = false;
768
+ }
769
+ if (judgeUnpaired || metricsUnpaired) valid = false;
770
+ if (submitBtn) submitBtn.disabled = !valid;
771
+
772
+ ckUpdateMetricGroupsState();
773
+ }
774
+
775
+ function ckToggleMetricGroup(button) {
776
+ var ids = (button.getAttribute('data-metric-ids') || '').split(',').filter(Boolean);
777
+ var allChecked = ids.every(function(id) {
778
+ var cb = document.getElementById('run_metric_' + id);
779
+ return cb && cb.checked;
780
+ });
781
+ ids.forEach(function(id) {
782
+ var cb = document.getElementById('run_metric_' + id);
783
+ if (cb) cb.checked = !allChecked;
784
+ });
785
+ ckUpdateRunForm();
786
+ }
787
+
788
+ function ckUpdateMetricGroupsState() {
789
+ document.querySelectorAll('[data-metric-group]').forEach(function(btn) {
790
+ var ids = (btn.getAttribute('data-metric-ids') || '').split(',').filter(Boolean);
791
+ if (ids.length === 0) return;
792
+ var allChecked = ids.every(function(id) {
793
+ var cb = document.getElementById('run_metric_' + id);
794
+ return cb && cb.checked;
795
+ });
796
+ btn.classList.toggle('ck-metric-group-pill--active', allChecked);
797
+ });
798
+ }
799
+
800
+ var ckRunFormChangeIds = ["run_judge_model", "run_prompt_id", "run_dataset_id", "run_judge_only"];
801
+
802
+ document.addEventListener("change", function(e) {
803
+ var target = e.target;
804
+ if (!target) return;
805
+ if (ckRunFormChangeIds.indexOf(target.id) !== -1 || (target.type === "checkbox" && target.name === "run[metric_ids][]")) ckUpdateRunForm();
806
+ });
807
+
808
+ document.addEventListener("input", function(e) {
809
+ if (e.target && e.target.id === "run_output_column") ckUpdateRunForm();
810
+ });
811
+
812
+ function ckInitRunForm() {
813
+ if (document.getElementById("run_judge_only")) ckUpdateRunForm();
814
+ }
815
+
816
+ function ckDatasetHeaderPreview() {
817
+ var area = document.getElementById("dataset_csv_data");
818
+ var box = document.getElementById("dataset-header-preview");
819
+ var chips = document.getElementById("dataset-header-chips");
820
+ if (!area || !box || !chips) return;
821
+ var recognized = { expected_output: "answer key", actual_output: "pre-made output" };
822
+ var firstLine = (area.value.split(/\r?\n/).find(function(l) { return l.trim() !== ""; }) || "");
823
+ var headers = firstLine.split(",").map(function(h) { return h.trim().replace(/^"|"$/g, ""); }).filter(Boolean);
824
+ if (headers.length === 0) { box.hidden = true; chips.textContent = ""; return; }
825
+ box.hidden = false;
826
+ chips.textContent = "";
827
+ headers.forEach(function(h) {
828
+ var role = recognized[h];
829
+ var chip = document.createElement("span");
830
+ chip.className = "ck-header-chip" + (role ? " ck-header-chip--special" : "");
831
+ chip.textContent = role ? h + " · " + role : h;
832
+ chips.appendChild(chip);
833
+ });
834
+ }
835
+
836
+ document.addEventListener("input", function(e) {
837
+ if (e.target && e.target.id === "dataset_csv_data") ckDatasetHeaderPreview();
838
+ });
839
+
840
+ function ckInitPageForms() {
841
+ ckInitRunForm();
842
+ ckDatasetHeaderPreview();
843
+ }
844
+
845
+ document.addEventListener("turbo:load", ckInitPageForms);
846
+ document.addEventListener("turbo:render", ckInitPageForms);
847
+
848
+ function ckStartRunStatusPoll() {
849
+ clearTimeout(window.ckRunStatusKick);
850
+ clearInterval(window.ckRunStatusPoll);
851
+ window.ckRunStatusKick = null;
852
+ window.ckRunStatusPoll = null;
853
+ var marker = document.querySelector("[data-ck-run-status-url]");
854
+ if (!marker) return;
855
+ var url = marker.getAttribute("data-ck-run-status-url");
856
+ var refresh = function() {
857
+ fetch(url, { headers: { Accept: "text/vnd.turbo-stream.html" }, credentials: "same-origin" })
858
+ .then(function(r) { return r.ok ? r.text() : null; })
859
+ .then(function(html) { if (html) Turbo.renderStreamMessage(html); })
860
+ .catch(function() {});
861
+ };
862
+ window.ckRunStatusKick = setTimeout(refresh, 1000);
863
+ window.ckRunStatusPoll = setInterval(refresh, 15000);
864
+ }
865
+
866
+ document.addEventListener("turbo:load", ckStartRunStatusPoll);
867
+
868
+ ckInitPageForms();
@@ -3,6 +3,7 @@ module CompletionKit
3
3
  PUBLIC_METHODS = %w[initialize notifications/initialized tools/list].freeze
4
4
 
5
5
  FOREIGN_KEY_MESSAGE = "This change references a record that does not exist or is still in use.".freeze
6
+ TIMEOUT_MESSAGE = "The input was too large or too complex to process.".freeze
6
7
 
7
8
  skip_before_action :authenticate_api!, only: [:stream, :handle], raise: false
8
9
 
@@ -47,6 +48,9 @@ module CompletionKit
47
48
  rescue ActiveRecord::InvalidForeignKey => e
48
49
  Rails.error.report(e, handled: true, context: { controller: "CompletionKit::McpController" })
49
50
  render json: jsonrpc_error(request_body.dig("id"), -32602, FOREIGN_KEY_MESSAGE), status: :ok
51
+ rescue Regexp::TimeoutError => e
52
+ Rails.error.report(e, handled: true, context: { controller: "CompletionKit::McpController" })
53
+ render json: jsonrpc_error(request_body.dig("id"), -32602, TIMEOUT_MESSAGE), status: :ok
50
54
  rescue StandardError => e
51
55
  Rails.error.report(e, handled: true, context: { controller: "CompletionKit::McpController" })
52
56
  render json: jsonrpc_error(request_body.dig("id"), -32603, "Internal error"), status: :ok
@@ -2,14 +2,17 @@ module CompletionKit
2
2
  class Dataset < ApplicationRecord
3
3
  include CompletionKit::Taggable
4
4
 
5
+ HEADER_ROW_LIMIT = 64 * 1024
6
+
5
7
  has_many :runs, dependent: :destroy
6
8
 
7
9
  validates :name, presence: true
8
- validates :csv_data, presence: true
10
+ validate :csv_data_present
9
11
  validate :csv_data_within_size_limit
12
+ validate :header_row_usable, if: :will_save_change_to_csv_data?
10
13
 
11
14
  def csv_data_within_size_limit
12
- return if csv_data.blank?
15
+ return if csv_data_blank?
13
16
  return if csv_data.bytesize <= CompletionKit.config.max_upload_bytes
14
17
 
15
18
  errors.add(:csv_data, "is too large (limit #{CompletionKit.config.max_upload_bytes / (1024 * 1024)} MB)")
@@ -37,12 +40,58 @@ module CompletionKit
37
40
  end
38
41
 
39
42
  def headers
40
- return [] if csv_data.blank?
43
+ row = header_row
44
+ return [] if row.nil?
41
45
 
42
46
  require "csv"
43
- ::CSV.parse(csv_data.lines.first.to_s).first.to_a.map(&:to_s).map(&:strip)
47
+ ::CSV.parse(row).first.to_a.map(&:to_s).map(&:strip)
44
48
  rescue ::CSV::MalformedCSVError
45
49
  []
46
50
  end
51
+
52
+ def any_rows?
53
+ stop = header_row_end
54
+ return false unless stop
55
+
56
+ stop += 1 if csv_data.byteslice(stop, 2) == "\r\n"
57
+ csv_data.bytesize > stop + 1
58
+ end
59
+
60
+ private
61
+
62
+ def csv_data_present
63
+ errors.add(:csv_data, :blank) if csv_data_blank?
64
+ end
65
+
66
+ def csv_data_blank?
67
+ csv_data.to_s.empty? || (csv_data.valid_encoding? && header_row.blank? && csv_data.lstrip.empty?)
68
+ end
69
+
70
+ def header_row
71
+ return if csv_data.to_s.empty?
72
+
73
+ stop = header_row_end
74
+ return csv_data.byteslice(0, stop) if stop
75
+
76
+ csv_data if csv_data.bytesize <= HEADER_ROW_LIMIT
77
+ end
78
+
79
+ def header_row_end
80
+ head = csv_data.to_s.byteslice(0, HEADER_ROW_LIMIT + 1)
81
+ [head.byteindex("\n"), head.byteindex("\r")].compact.min
82
+ end
83
+
84
+ def header_row_usable
85
+ return if csv_data_blank?
86
+
87
+ row = header_row
88
+ if !csv_data.valid_encoding?
89
+ errors.add(:csv_data, "is not valid UTF-8")
90
+ elsif row.nil?
91
+ errors.add(:csv_data, "has a header row longer than #{HEADER_ROW_LIMIT / 1024} KB")
92
+ elsif row.blank?
93
+ errors.add(:csv_data, "has a blank header row")
94
+ end
95
+ end
47
96
  end
48
97
  end
@@ -205,7 +205,7 @@ module CompletionKit
205
205
  end
206
206
 
207
207
  def validate_check_pattern(config)
208
- Regexp.new(config["pattern"].to_s)
208
+ CompletionKit::Checks::Regex.build(config)
209
209
  rescue RegexpError
210
210
  add_check_error("needs #{check_label("pattern")} to be a valid regular expression", "pattern")
211
211
  end
@@ -9,6 +9,7 @@ module CompletionKit
9
9
 
10
10
  validates :name, presence: true
11
11
  validates :template, presence: true
12
+ validate :template_within_size_limit, if: :will_save_change_to_template?
12
13
  validates :llm_model, presence: true
13
14
  validate :llm_model_usable_for_generation, if: :llm_model_newly_selected?
14
15
  validates :family_key, presence: true
@@ -97,6 +98,13 @@ module CompletionKit
97
98
 
98
99
  private
99
100
 
101
+ def template_within_size_limit
102
+ limit = CompletionKit.config.max_template_bytes
103
+ return if template.to_s.bytesize <= limit
104
+
105
+ errors.add(:template, "is too large (limit #{ActiveSupport::NumberHelper.number_to_human_size(limit)})")
106
+ end
107
+
100
108
  def llm_model_newly_selected?
101
109
  return false if llm_model.blank?
102
110
  return llm_model != cloned_from_llm_model if new_record? && cloned_from_llm_model.present?
@@ -336,7 +336,7 @@ module CompletionKit
336
336
  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.")
337
337
  end
338
338
 
339
- return fail_with_summary!("Dataset has no rows") if dataset && dataset.row_count.zero?
339
+ return fail_with_summary!("Dataset has no rows") if dataset && !dataset.any_rows?
340
340
 
341
341
  if judge_only?
342
342
  column = output_column.presence || "actual_output"
@@ -10,6 +10,7 @@ module CompletionKit
10
10
  def self.for_provider(provider_name)
11
11
  provider = provider_name.to_s
12
12
  stored = ProviderCredential.find_by(provider: provider)&.config_hash || {}
13
+ stored = stored.except(:api_key) if provider == "ollama" && stored[:api_key].blank?
13
14
 
14
15
  defaults = if CompletionKit.config.tenant_scope
15
16
  PROVIDERS.include?(provider) ? { provider: provider } : {}
@@ -22,7 +23,7 @@ module CompletionKit
22
23
  when "ollama"
23
24
  {
24
25
  provider: "ollama",
25
- api_key: CompletionKit.config.ollama_api_key || ENV["OLLAMA_API_KEY"],
26
+ api_key: OllamaClient.fallback_api_key(stored[:api_endpoint] || OllamaClient.configured_endpoint),
26
27
  api_endpoint: CompletionKit.config.ollama_api_endpoint || ENV["OLLAMA_API_ENDPOINT"]
27
28
  }
28
29
  when "openrouter"
@@ -1,17 +1,27 @@
1
1
  module CompletionKit
2
2
  module Checks
3
3
  class Regex
4
- def call(target, config)
4
+ def self.build(config)
5
5
  options = 0
6
6
  options |= Regexp::IGNORECASE unless Flag.on?(config, "case_sensitive")
7
7
  options |= Regexp::MULTILINE if Flag.on?(config, "multiline")
8
- pattern = Regexp.new(config["pattern"].to_s, options)
8
+ Regexp.new(config["pattern"].to_s, options, timeout: timeout)
9
+ end
10
+
11
+ def self.timeout
12
+ [CompletionKit.config.regex_timeout, Regexp.timeout].compact.min
13
+ end
14
+
15
+ def call(target, config)
16
+ pattern = self.class.build(config)
9
17
 
10
18
  if pattern.match?(target.to_s)
11
19
  Result.new(passed: true, detail: "matched /#{config["pattern"]}/")
12
20
  else
13
21
  Result.new(passed: false, detail: "no match for /#{config["pattern"]}/")
14
22
  end
23
+ rescue Regexp::TimeoutError
24
+ Result.new(passed: false, detail: "timed out after #{pattern.timeout || Regexp.timeout}s matching /#{config["pattern"]}/")
15
25
  rescue RegexpError => e
16
26
  Result.new(passed: false, detail: "invalid pattern: #{e.message}")
17
27
  end