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.
- checksums.yaml +4 -4
- data/README.md +48 -3
- data/app/assets/javascripts/completion_kit/application.js +383 -0
- data/app/controllers/completion_kit/mcp_controller.rb +4 -0
- data/app/models/completion_kit/dataset.rb +53 -4
- data/app/models/completion_kit/metric.rb +1 -1
- data/app/models/completion_kit/prompt.rb +8 -0
- data/app/models/completion_kit/run.rb +1 -1
- data/app/services/completion_kit/api_config.rb +2 -1
- data/app/services/completion_kit/checks/regex.rb +12 -2
- data/app/services/completion_kit/csv_processor.rb +14 -2
- data/app/services/completion_kit/model_discovery_service.rb +2 -1
- data/app/services/completion_kit/ollama_client.rb +19 -2
- data/app/services/completion_kit/provider_endpoint.rb +10 -0
- data/app/views/completion_kit/api_reference/_authentication.html.erb +2 -2
- data/app/views/completion_kit/api_reference/_body.html.erb +0 -27
- data/app/views/completion_kit/api_reference/_example.html.erb +1 -1
- data/app/views/completion_kit/api_reference/_resource_card.html.erb +1 -1
- data/app/views/completion_kit/datasets/_form.html.erb +0 -28
- data/app/views/completion_kit/datasets/index.html.erb +1 -1
- data/app/views/completion_kit/metric_groups/_form.html.erb +1 -20
- data/app/views/completion_kit/metric_groups/index.html.erb +1 -1
- data/app/views/completion_kit/metrics/_starter_card.html.erb +1 -1
- data/app/views/completion_kit/metrics/index.html.erb +2 -2
- data/app/views/completion_kit/metrics/show.html.erb +4 -14
- data/app/views/completion_kit/prompts/_form.html.erb +1 -1
- data/app/views/completion_kit/prompts/index.html.erb +3 -3
- data/app/views/completion_kit/prompts/show.html.erb +8 -8
- data/app/views/completion_kit/provider_credentials/_models_card.html.erb +2 -2
- data/app/views/completion_kit/runs/_form.html.erb +3 -179
- data/app/views/completion_kit/runs/_response_row.html.erb +2 -3
- data/app/views/completion_kit/runs/_row.html.erb +3 -3
- data/app/views/completion_kit/runs/_run_config.html.erb +1 -1
- data/app/views/completion_kit/runs/show.html.erb +5 -19
- data/app/views/completion_kit/tags/index.html.erb +1 -1
- data/app/views/layouts/completion_kit/application.html.erb +2 -2
- data/lib/completion_kit/version.rb +1 -1
- data/lib/completion_kit.rb +14 -0
- data/lib/generators/completion_kit/templates/initializer.rb +18 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f314f2989362808e829e3f1bbbfc5718eca193aa4602ae4b4b3e72bc6c9251c1
|
|
4
|
+
data.tar.gz: b9322db963ff8bb00660e09309940f692cab0fff289c8069b3be68ecf81db429
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
+
row = header_row
|
|
44
|
+
return [] if row.nil?
|
|
41
45
|
|
|
42
46
|
require "csv"
|
|
43
|
-
::CSV.parse(
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|