cuttlely 1.0.3 → 1.2.0
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.
- package/README.md +35 -9
- package/lib/check.js +201 -11
- package/lib/cli.js +103 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,10 +12,10 @@ Requires Node.js 20 or newer, and Docker.
|
|
|
12
12
|
npx cuttlely start
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
That pulls `ghcr.io/cuttlely/cuttlely
|
|
15
|
+
That pulls `ghcr.io/cuttlely/cuttlely`, tagged with the same version as the `cuttlely` package you ran, keeps data in the Docker volume `cuttlely-data`, publishes port **43117**, waits until `http://127.0.0.1:43117/api/v1/ping` answers `pong`, prints `http://localhost:43117`, and opens it.
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npx cuttlely start --port 8080 --tag 1.
|
|
18
|
+
npx cuttlely start --port 8080 --tag 1.1.0 --env-file .env --no-open
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
| Flag | Meaning |
|
|
@@ -50,13 +50,13 @@ export CUTTLELY_API_KEY=<key>
|
|
|
50
50
|
npx cuttlely check --flow <flow id> --server http://127.0.0.1:43117 --timeout 300 --json check.json --fail-on-changed
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
| Flag | Meaning
|
|
54
|
-
| ------------------- |
|
|
55
|
-
| `--flow` | Flow id from the canvas address bar. Required.
|
|
56
|
-
| `--server` | Server origin. Default `http://127.0.0.1:43117`.
|
|
57
|
-
| `--timeout` | Seconds to wait. Default 300.
|
|
58
|
-
| `--json` | Write the full check JSON to this path.
|
|
59
|
-
| `--fail-on-changed` | Exit 1 when any answer is Changed. Without it, a finished check exits 0 and the summary still lists them.
|
|
53
|
+
| Flag | Meaning |
|
|
54
|
+
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `--flow` | Flow id from the canvas address bar. Required. |
|
|
56
|
+
| `--server` | Server origin. Default `http://127.0.0.1:43117`. |
|
|
57
|
+
| `--timeout` | Seconds to wait for the check. Default 300. The final confirmation read after a `--gate` pass has its own window of at most 30 seconds. |
|
|
58
|
+
| `--json` | Write the full check JSON to this path. |
|
|
59
|
+
| `--fail-on-changed` | Exit 1 when any answer is Changed. Without it, a finished check exits 0 and the summary still lists them. |
|
|
60
60
|
|
|
61
61
|
`pnpm dev` serves the API on port 3000. `scripts/start-cuttlely.sh` and `npx cuttlely start` listen on port 43117.
|
|
62
62
|
|
|
@@ -70,6 +70,32 @@ A check the server marks stopped or failed also exits 2. If the draft is saved,
|
|
|
70
70
|
|
|
71
71
|
The key needs both permissions for the flow: `chatflows:view` and `chatflows:update`, or `agentflows:view` and `agentflows:update`. Update starts the check. View is what reading the result uses. The guide is [Fail a build when answers change](https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/ci-gate.md).
|
|
72
72
|
|
|
73
|
+
## Score a test set
|
|
74
|
+
|
|
75
|
+
`--dataset` or `--gate` asks a dataset instead of the four recent questions. The command exits on the server verdict. It does not score the answers itself.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx cuttlely check --flow <flow id> --dataset <dataset id> --evaluator <evaluator id> --threshold 80
|
|
79
|
+
npx cuttlely check --flow <flow id> --gate
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Flag | Meaning |
|
|
83
|
+
| ------------- | ----------------------------------------------------------------------------------------- |
|
|
84
|
+
| `--dataset` | Dataset id. Selects scored mode. |
|
|
85
|
+
| `--evaluator` | Evaluator id. Repeat for another evaluator. |
|
|
86
|
+
| `--threshold` | Pass mark from 0 to 100. Omit it and the server uses 100. |
|
|
87
|
+
| `--gate` | Dataset, evaluators, and threshold saved on the flow. Do not combine it with `--dataset`. |
|
|
88
|
+
|
|
89
|
+
Do not combine those flags with `--fail-on-changed`. The gate covers Publish of the current draft only. Roll back, Promote, and `PUT /api/v1/chatflows/:id` can still change Live. A harness cannot store a passing test set. `--dataset` can score a harness for that run only. `--gate` on a harness exits 2 before any check.
|
|
90
|
+
|
|
91
|
+
| Code | When |
|
|
92
|
+
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
93
|
+
| 0 | The server verdict is passed. With `--gate`, a second read of the flow still stores this check as passed with an empty `block` (`publishGate.enabled` is true, `publishGate.result.checkId` is this check, and `publishGate.result.verdict` is `passed`). |
|
|
94
|
+
| 1 | The server verdict is failed. |
|
|
95
|
+
| 2 | The same failures as the question check, a missing verdict, a usage error, or a withheld dataset or evaluator id. `--gate` also exits 2 when the confirmation read fails: a non-empty block, a different checkId, or a missing passed. Turning the passing test set off during a `--gate` check, after the server verdict is `passed`, exits 2. The message is `The saved test set changed during the check. Run it again.` when the server sends no `block` text. Changing the dataset, the evaluators, or the pass mark during that check exits 2 the same way, and a `block` from the server is printed instead. A failed verdict exits 1 and does not read the flow again. |
|
|
96
|
+
|
|
97
|
+
A 403 names the flow view and update permissions, plus `datasets:view` and `evaluators:view`. The summary prints the pass rate, the threshold, and the cases whose draft scores are not Pass. `--json` writes the full check, including each score.
|
|
98
|
+
|
|
73
99
|
The container name is `cuttlely`. `start` leaves it running when it is already on the same image and port. `upgrade` always pulls and starts it again. The volume is `cuttlely-data`, mounted at `/var/cuttlely`.
|
|
74
100
|
|
|
75
101
|
Compose uses the volume name `cuttlely_cuttlely-data`. This launcher uses `cuttlely-data`.
|
package/lib/check.js
CHANGED
|
@@ -12,6 +12,9 @@ export const EXIT_PASS = 0
|
|
|
12
12
|
export const EXIT_CHANGED = 1
|
|
13
13
|
export const EXIT_ERROR = 2
|
|
14
14
|
|
|
15
|
+
// Flow, dataset, and evaluator ids share this shape.
|
|
16
|
+
export const RESOURCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
|
|
17
|
+
|
|
15
18
|
const ANSWER_CLIP = 240
|
|
16
19
|
|
|
17
20
|
function redact(text, secret) {
|
|
@@ -72,14 +75,52 @@ function errorCases(check) {
|
|
|
72
75
|
return (check?.cases || []).filter((row) => row && (row.live?.error || row.draft?.error))
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
function verdictLine(verdict) {
|
|
79
|
+
const rate = verdict.passRate == null ? 'no scored answers' : `${verdict.passRate}%`
|
|
80
|
+
const mark = verdict.threshold == null ? 'n/a' : `${verdict.threshold}%`
|
|
81
|
+
const passed = Number(verdict.passed ?? 0)
|
|
82
|
+
const failed = Number(verdict.failed ?? 0)
|
|
83
|
+
const errored = Number(verdict.errored ?? 0)
|
|
84
|
+
return `Pass rate: ${rate} (threshold ${mark}). ${passed} passed, ${failed} failed, ${errored} errored.`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function failingCases(check) {
|
|
88
|
+
return (check?.cases || []).filter((row) => {
|
|
89
|
+
const scores = row?.draft?.scores
|
|
90
|
+
if (!Array.isArray(scores) || !scores.length) return false
|
|
91
|
+
return scores.some((score) => score && score.result !== 'Pass')
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function failingLines(check) {
|
|
96
|
+
const rows = failingCases(check)
|
|
97
|
+
if (!rows.length) return []
|
|
98
|
+
const lines = ['', 'Failing']
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
lines.push(` ${oneLine(row.question)}`)
|
|
101
|
+
for (const score of row.draft.scores) {
|
|
102
|
+
if (!score || score.result === 'Pass') continue
|
|
103
|
+
lines.push(` ${score.name ? `${score.name}: ${score.result}` : score.result}`)
|
|
104
|
+
}
|
|
105
|
+
if (row.expected) lines.push(` Expected: ${oneLine(row.expected)}`)
|
|
106
|
+
if (row.draft?.text) lines.push(` Draft: ${oneLine(row.draft.text)}`)
|
|
107
|
+
else if (row.draft?.error) lines.push(` ${oneLine(row.draft.error)}`)
|
|
108
|
+
}
|
|
109
|
+
return lines
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
export function summaryText(check) {
|
|
76
113
|
const summary = check.summary || {}
|
|
77
114
|
const lines = [
|
|
78
115
|
`Check ${check.id} ${check.status}`,
|
|
79
|
-
`Questions: ${summary.ran ?? 0} ran, ${summary.same ?? 0} same, ${summary.changed ?? 0} changed, ${summary.errors ?? 0} errors
|
|
116
|
+
`Questions: ${summary.ran ?? 0} ran, ${summary.same ?? 0} same, ${summary.changed ?? 0} changed, ${summary.errors ?? 0} errors`
|
|
117
|
+
]
|
|
118
|
+
// Present only when the server finished a dataset check. Diff mode has no verdict.
|
|
119
|
+
if (check.verdict && typeof check.verdict === 'object') lines.push(verdictLine(check.verdict))
|
|
120
|
+
lines.push(
|
|
80
121
|
`Live: ${summary.liveTokens ?? 0} tokens, median ${formatMs(summary.liveMedianMs)}`,
|
|
81
122
|
`Draft: ${summary.draftTokens ?? 0} tokens, median ${formatMs(summary.draftMedianMs)}`
|
|
82
|
-
|
|
123
|
+
)
|
|
83
124
|
// The check total, with each model priced once. It is not the sum of the per-answer figures.
|
|
84
125
|
const liveEstimate = estimateLine('Live', summary.liveEstimatedCostUsd, summary.livePrice)
|
|
85
126
|
const draftEstimate = estimateLine('Draft', summary.draftEstimatedCostUsd, summary.draftPrice)
|
|
@@ -106,11 +147,11 @@ export function summaryText(check) {
|
|
|
106
147
|
lines.push(` ${oneLine(why)}`)
|
|
107
148
|
}
|
|
108
149
|
}
|
|
150
|
+
if (check.verdict && typeof check.verdict === 'object') lines.push(...failingLines(check))
|
|
109
151
|
return lines.join('\n')
|
|
110
152
|
}
|
|
111
153
|
|
|
112
|
-
// Replay is the
|
|
113
|
-
// from a test set) should return the same { code, label } and be selected in selectVerdict.
|
|
154
|
+
// Replay is the question check. Scored mode reads the server verdict and does not rescore.
|
|
114
155
|
export function replayVerdict(check, { failOnChanged }) {
|
|
115
156
|
if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
|
|
116
157
|
if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
|
|
@@ -123,7 +164,21 @@ export function replayVerdict(check, { failOnChanged }) {
|
|
|
123
164
|
return { code: EXIT_PASS, label: 'pass' }
|
|
124
165
|
}
|
|
125
166
|
|
|
167
|
+
// Exit 1 is the server's failed verdict. A missing verdict is an error, not a guess from the pass rate.
|
|
168
|
+
export function scoredVerdict(check) {
|
|
169
|
+
if (!check || typeof check !== 'object') return { code: EXIT_ERROR, label: 'error' }
|
|
170
|
+
if (check.status === 'failed') return { code: EXIT_ERROR, label: 'failed' }
|
|
171
|
+
if (check.status === 'stopped') return { code: EXIT_ERROR, label: 'stopped' }
|
|
172
|
+
if (check.status !== 'done') return { code: EXIT_ERROR, label: 'incomplete' }
|
|
173
|
+
if (check.draftChanged) return { code: EXIT_ERROR, label: 'draft-changed' }
|
|
174
|
+
const result = check.verdict && check.verdict.result
|
|
175
|
+
if (result === 'passed') return { code: EXIT_PASS, label: 'passed' }
|
|
176
|
+
if (result === 'failed') return { code: EXIT_CHANGED, label: 'score-failed' }
|
|
177
|
+
return { code: EXIT_ERROR, label: 'no-verdict' }
|
|
178
|
+
}
|
|
179
|
+
|
|
126
180
|
export function selectVerdict(check, options) {
|
|
181
|
+
if (options && options.scored) return scoredVerdict(check)
|
|
127
182
|
return replayVerdict(check, options)
|
|
128
183
|
}
|
|
129
184
|
|
|
@@ -171,8 +226,98 @@ function isNoDraft(result) {
|
|
|
171
226
|
|
|
172
227
|
// Starting a check needs update. Reading it needs view. A key with only one of those gets 403,
|
|
173
228
|
// and the server message does not say which permission is missing.
|
|
174
|
-
function permissionDeniedMessage() {
|
|
175
|
-
|
|
229
|
+
function permissionDeniedMessage(scored) {
|
|
230
|
+
const flow = 'The API key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.'
|
|
231
|
+
if (!scored) return flow
|
|
232
|
+
return `${flow} Scored mode also needs datasets:view and evaluators:view.`
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function scoreFailedLine(verdict) {
|
|
236
|
+
const rate = !verdict || verdict.passRate == null ? 'no scored answers' : `${verdict.passRate}%`
|
|
237
|
+
const mark = !verdict || verdict.threshold == null ? 'n/a' : `${verdict.threshold}%`
|
|
238
|
+
return `The test set failed at ${rate} (threshold ${mark}).`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const STALE_GATE = 'The saved test set changed during the check. Run it again.'
|
|
242
|
+
// The check can use the whole --timeout. The confirmation read gets its own window, capped so a hung read cannot double a 3600s wait.
|
|
243
|
+
const CONFIRM_READ_CAP_SECONDS = 30
|
|
244
|
+
|
|
245
|
+
function confirmReadSeconds(timeoutSeconds) {
|
|
246
|
+
return Math.min(timeoutSeconds, CONFIRM_READ_CAP_SECONDS)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// An enabled gate always stores a dataset and at least one evaluator. A read that
|
|
250
|
+
// omits them withheld the ids: datasets:view and evaluators:view are what reveal them.
|
|
251
|
+
function withheldIdsMessage(datasetMissing, evaluatorsMissing) {
|
|
252
|
+
if (datasetMissing && evaluatorsMissing) {
|
|
253
|
+
return 'The response did not include the dataset id or the evaluator ids. The API key needs datasets:view and evaluators:view.'
|
|
254
|
+
}
|
|
255
|
+
if (datasetMissing) return 'The response did not include the dataset id. The API key needs datasets:view.'
|
|
256
|
+
return 'The response did not include the evaluator ids. The API key needs evaluators:view.'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function datasetIdMissing(gate) {
|
|
260
|
+
return typeof gate.datasetId !== 'string' || gate.datasetId.trim() === ''
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function evaluatorIdsMissing(gate) {
|
|
264
|
+
return !Array.isArray(gate.evaluatorIds) || gate.evaluatorIds.length === 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --gate copies the dataset, evaluators, and threshold the flow already saved.
|
|
268
|
+
function gateSettings(body) {
|
|
269
|
+
const gate = body && body.publishGate
|
|
270
|
+
if (!gate || gate.enabled !== true) {
|
|
271
|
+
return { ok: false, message: 'This flow has no saved test set. Turn the passing test set on, or pass --dataset.' }
|
|
272
|
+
}
|
|
273
|
+
const datasetMissing = datasetIdMissing(gate)
|
|
274
|
+
const evaluatorsMissing = evaluatorIdsMissing(gate)
|
|
275
|
+
if (datasetMissing || evaluatorsMissing) return { ok: false, message: withheldIdsMessage(datasetMissing, evaluatorsMissing) }
|
|
276
|
+
if (!RESOURCE_ID_RE.test(gate.datasetId)) {
|
|
277
|
+
return { ok: false, message: 'The saved test set has no dataset id.' }
|
|
278
|
+
}
|
|
279
|
+
const evaluatorIds = []
|
|
280
|
+
const seen = new Set()
|
|
281
|
+
for (const id of gate.evaluatorIds) {
|
|
282
|
+
if (typeof id !== 'string' || !RESOURCE_ID_RE.test(id)) {
|
|
283
|
+
return { ok: false, message: 'The saved test set has an evaluator id this command cannot use.' }
|
|
284
|
+
}
|
|
285
|
+
if (seen.has(id)) continue
|
|
286
|
+
seen.add(id)
|
|
287
|
+
evaluatorIds.push(id)
|
|
288
|
+
}
|
|
289
|
+
let threshold = null
|
|
290
|
+
if (gate.threshold != null) {
|
|
291
|
+
if (typeof gate.threshold !== 'number' || !Number.isInteger(gate.threshold) || gate.threshold < 0 || gate.threshold > 100) {
|
|
292
|
+
return { ok: false, message: 'The saved pass mark is not a whole number from 0 to 100.' }
|
|
293
|
+
}
|
|
294
|
+
threshold = gate.threshold
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, datasetId: gate.datasetId, evaluatorIds, threshold }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A passed --gate check is current only when Publish would still accept this run.
|
|
300
|
+
function gateBlockMessage(gate) {
|
|
301
|
+
if (gate && typeof gate.block === 'string' && gate.block.trim()) return gate.block
|
|
302
|
+
return STALE_GATE
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function gatePassStillCurrent(body, checkId) {
|
|
306
|
+
const gate = body && body.publishGate
|
|
307
|
+
const message = gateBlockMessage(gate)
|
|
308
|
+
if (!gate || gate.enabled !== true) return { ok: false, message }
|
|
309
|
+
if (typeof gate.block === 'string' && gate.block.trim()) return { ok: false, message }
|
|
310
|
+
const result = gate.result
|
|
311
|
+
if (!result || result.checkId !== checkId || result.verdict !== 'passed') return { ok: false, message }
|
|
312
|
+
return { ok: true }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function requestBody({ scored, datasetId, evaluatorIds, threshold }) {
|
|
316
|
+
if (!scored) return { count: DEFAULT_QUESTION_COUNT }
|
|
317
|
+
const body = { datasetId }
|
|
318
|
+
if (evaluatorIds && evaluatorIds.length) body.evaluatorIds = evaluatorIds
|
|
319
|
+
if (threshold != null) body.threshold = threshold
|
|
320
|
+
return body
|
|
176
321
|
}
|
|
177
322
|
|
|
178
323
|
function writeResult(writeFile, jsonPath, check, secret) {
|
|
@@ -208,7 +353,12 @@ export async function runCheck(options, io = {}) {
|
|
|
208
353
|
const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS
|
|
209
354
|
const flow = options.flow
|
|
210
355
|
const failOnChanged = Boolean(options.failOnChanged)
|
|
211
|
-
const
|
|
356
|
+
const scored = Boolean(options.scored || options.gate)
|
|
357
|
+
let datasetId = options.datasetId
|
|
358
|
+
let evaluatorIds = options.evaluatorIds || []
|
|
359
|
+
let threshold = options.threshold ?? null
|
|
360
|
+
const flowUrl = `${server}/api/v1/flow-drafts/${encodeURIComponent(flow)}`
|
|
361
|
+
const base = `${flowUrl}/check`
|
|
212
362
|
const headers = { Authorization: `Bearer ${secret}`, Accept: 'application/json' }
|
|
213
363
|
const deadline = now() + timeoutSeconds * 1000
|
|
214
364
|
|
|
@@ -255,16 +405,34 @@ export async function runCheck(options, io = {}) {
|
|
|
255
405
|
}
|
|
256
406
|
|
|
257
407
|
try {
|
|
408
|
+
if (options.gate) {
|
|
409
|
+
const loaded = await callApi(fetchImpl, flowUrl, {
|
|
410
|
+
method: 'GET',
|
|
411
|
+
headers,
|
|
412
|
+
signal: deadlineSignal(deadline, now)
|
|
413
|
+
})
|
|
414
|
+
if (loaded.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
|
|
415
|
+
if (loaded.network) return fail(`Could not reach ${server}. ${loaded.message}`)
|
|
416
|
+
if (!loaded.ok) {
|
|
417
|
+
if (loaded.status === 403) return fail(permissionDeniedMessage(true))
|
|
418
|
+
return fail(`Check failed (${loaded.status}): ${loaded.message}`)
|
|
419
|
+
}
|
|
420
|
+
const parsed = gateSettings(loaded.json)
|
|
421
|
+
if (!parsed.ok) return fail(parsed.message)
|
|
422
|
+
datasetId = parsed.datasetId
|
|
423
|
+
evaluatorIds = parsed.evaluatorIds
|
|
424
|
+
threshold = parsed.threshold
|
|
425
|
+
}
|
|
258
426
|
const started = await callApi(fetchImpl, base, {
|
|
259
427
|
method: 'POST',
|
|
260
428
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
261
|
-
body: JSON.stringify({
|
|
429
|
+
body: JSON.stringify(requestBody({ scored, datasetId, evaluatorIds, threshold })),
|
|
262
430
|
signal: deadlineSignal(deadline, now)
|
|
263
431
|
})
|
|
264
432
|
if (started.timeout) return fail(`Timed out after ${timeoutSeconds}s before the check started.`)
|
|
265
433
|
if (started.network) return fail(`Could not reach ${server}. ${started.message}`)
|
|
266
434
|
if (!started.ok) {
|
|
267
|
-
if (started.status === 403) return fail(permissionDeniedMessage())
|
|
435
|
+
if (started.status === 403) return fail(permissionDeniedMessage(scored))
|
|
268
436
|
if (isNoDraft(started)) return fail(`No draft to check. ${started.message}`)
|
|
269
437
|
return fail(`Check failed (${started.status}): ${started.message}`)
|
|
270
438
|
}
|
|
@@ -293,7 +461,7 @@ export async function runCheck(options, io = {}) {
|
|
|
293
461
|
})
|
|
294
462
|
if (polled.timeout) return giveUp()
|
|
295
463
|
if (polled.network) return failRunning(`Could not reach ${server}. ${polled.message}`)
|
|
296
|
-
if (polled.status === 403) return failRunning(permissionDeniedMessage())
|
|
464
|
+
if (polled.status === 403) return failRunning(permissionDeniedMessage(scored))
|
|
297
465
|
if (!polled.ok) return failRunning(`Check failed (${polled.status}): ${polled.message}`)
|
|
298
466
|
if (!polled.json || typeof polled.json !== 'object') return failRunning('The server did not return a check.')
|
|
299
467
|
const previous = check.completed
|
|
@@ -316,7 +484,7 @@ export async function runCheck(options, io = {}) {
|
|
|
316
484
|
return EXIT_ERROR
|
|
317
485
|
}
|
|
318
486
|
out(say(summaryText(check)))
|
|
319
|
-
const verdict = selectVerdict(check, { failOnChanged })
|
|
487
|
+
const verdict = selectVerdict(check, { failOnChanged, scored })
|
|
320
488
|
if (verdict.label === 'draft-changed') {
|
|
321
489
|
err('The draft changed while the check was running, so the result is out of date.')
|
|
322
490
|
} else if (verdict.label === 'failed') {
|
|
@@ -326,10 +494,32 @@ export async function runCheck(options, io = {}) {
|
|
|
326
494
|
} else if (verdict.label === 'changed') {
|
|
327
495
|
const count = Number(check.summary?.changed ?? 0)
|
|
328
496
|
err(count === 1 ? '1 answer changed.' : `${count} answers changed.`)
|
|
497
|
+
} else if (verdict.label === 'score-failed') {
|
|
498
|
+
err(scoreFailedLine(check.verdict))
|
|
499
|
+
} else if (verdict.label === 'no-verdict') {
|
|
500
|
+
err('The server did not return a verdict for this test set.')
|
|
329
501
|
} else if (verdict.code === EXIT_ERROR) {
|
|
330
502
|
err('The check did not finish.')
|
|
331
503
|
}
|
|
332
504
|
if (verdict.code === EXIT_ERROR) await stopIfStarted()
|
|
505
|
+
// --gate can exit 0 only when this run is still the stored pass. A threshold,
|
|
506
|
+
// row, or evaluator change during the check makes Publish refuse it.
|
|
507
|
+
if (options.gate && verdict.code === EXIT_PASS) {
|
|
508
|
+
const confirmSeconds = confirmReadSeconds(timeoutSeconds)
|
|
509
|
+
const again = await callApi(fetchImpl, flowUrl, {
|
|
510
|
+
method: 'GET',
|
|
511
|
+
headers,
|
|
512
|
+
signal: deadlineSignal(now() + confirmSeconds * 1000, now)
|
|
513
|
+
})
|
|
514
|
+
if (again.timeout) return fail(`Timed out after ${confirmSeconds}s before the saved test set could be read again.`)
|
|
515
|
+
if (again.network) return fail(`Could not reach ${server}. ${again.message}`)
|
|
516
|
+
if (!again.ok) {
|
|
517
|
+
if (again.status === 403) return fail(permissionDeniedMessage(true))
|
|
518
|
+
return fail(`Check failed (${again.status}): ${again.message}`)
|
|
519
|
+
}
|
|
520
|
+
const current = gatePassStillCurrent(again.json, check.id)
|
|
521
|
+
if (!current.ok) return fail(current.message)
|
|
522
|
+
}
|
|
333
523
|
return verdict.code
|
|
334
524
|
} catch (error) {
|
|
335
525
|
await stopIfStarted()
|
package/lib/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync, statSync } from 'node:fs'
|
|
|
4
4
|
import { spawn } from 'node:child_process'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
-
import { runCheck } from './check.js'
|
|
7
|
+
import { RESOURCE_ID_RE, runCheck } from './check.js'
|
|
8
8
|
import { spawnDocker } from './docker.js'
|
|
9
9
|
|
|
10
10
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
@@ -39,6 +39,8 @@ export function helpText() {
|
|
|
39
39
|
' cuttlely logs [--follow]',
|
|
40
40
|
' cuttlely upgrade [--port <n>] [--tag <tag>] [--env-file <path>] [--no-open]',
|
|
41
41
|
' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] [--fail-on-changed]',
|
|
42
|
+
' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] --dataset <id> [--evaluator <id> ...] [--threshold <0-100>]',
|
|
43
|
+
' cuttlely check --flow <id> [--server <url>] [--timeout <seconds>] [--json <path>] --gate',
|
|
42
44
|
' cuttlely --help',
|
|
43
45
|
' cuttlely --version',
|
|
44
46
|
'',
|
|
@@ -54,13 +56,15 @@ export function helpText() {
|
|
|
54
56
|
' upgrade Pull the image and start it again.',
|
|
55
57
|
' check Run the Publish check on a flow draft. Reads CUTTLELY_API_KEY.',
|
|
56
58
|
' The key needs chatflows:view and chatflows:update, or agentflows:view and agentflows:update.',
|
|
59
|
+
' --dataset or --gate also needs datasets:view and evaluators:view.',
|
|
57
60
|
'',
|
|
58
61
|
'Exit codes for check:',
|
|
59
|
-
' 0 The check finished. No answer was Changed, or --fail-on-changed was off.',
|
|
60
|
-
' 1 --fail-on-changed was set and at least one answer was Changed.',
|
|
62
|
+
' 0 The check finished. No answer was Changed, or --fail-on-changed was off. A scored check exits 0 when the server verdict is passed.',
|
|
63
|
+
' 1 --fail-on-changed was set and at least one answer was Changed. A scored check exits 1 when the server verdict is failed.',
|
|
61
64
|
' 2 The check timed out, the server returned an error, CUTTLELY_API_KEY was missing, the flow has no draft, or the command was used wrong.',
|
|
62
65
|
'A check the server marks stopped or failed also exits 2. If the draft is saved, published, or discarded while the check runs, the command exits 2, because the answers may be for a draft that is already gone. After the check has started, a timeout, a 403, or any other failure asks the server to stop it. That stop is best effort: if it fails, the exit code is still 2.',
|
|
63
66
|
'Cancelling the command stops the server-side check.',
|
|
67
|
+
'A scored check (--dataset or --gate) exits on the server verdict and does not score the answers itself. --gate reads the dataset, evaluators, and threshold saved on the flow, and reads the flow again after a pass. Exit 0 only when that second read still stores this check as passed with no block. Otherwise exit 2. Do not combine --gate with --dataset, or those flags with --fail-on-changed.',
|
|
64
68
|
'',
|
|
65
69
|
`Install guide: ${INSTALL_DOCS}`
|
|
66
70
|
].join('\n')
|
|
@@ -82,7 +86,19 @@ function parseTag(value, exitCode = 1) {
|
|
|
82
86
|
|
|
83
87
|
// These options take the next argv token as their value. A following `check` is that value, not the command.
|
|
84
88
|
// Any other previous token, including an unknown flag, leaves `check` as the command.
|
|
85
|
-
const VALUE_OPTIONS = new Set([
|
|
89
|
+
const VALUE_OPTIONS = new Set([
|
|
90
|
+
'--flow',
|
|
91
|
+
'--server',
|
|
92
|
+
'--timeout',
|
|
93
|
+
'--json',
|
|
94
|
+
'--dataset',
|
|
95
|
+
'--evaluator',
|
|
96
|
+
'--threshold',
|
|
97
|
+
'--port',
|
|
98
|
+
'-p',
|
|
99
|
+
'--tag',
|
|
100
|
+
'--env-file'
|
|
101
|
+
])
|
|
86
102
|
|
|
87
103
|
export function isCheckCommand(argv) {
|
|
88
104
|
return argv.some((item, index) => {
|
|
@@ -97,15 +113,27 @@ export function commandFailureCode(argv) {
|
|
|
97
113
|
return isCheckCommand(argv) ? 2 : 1
|
|
98
114
|
}
|
|
99
115
|
|
|
100
|
-
const FLOW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
|
|
101
|
-
|
|
102
116
|
function parseFlow(value) {
|
|
103
|
-
if (!value || !
|
|
117
|
+
if (!value || !RESOURCE_ID_RE.test(value)) {
|
|
104
118
|
throw new UsageError('--flow needs the flow id from the canvas address bar.', 2)
|
|
105
119
|
}
|
|
106
120
|
return value
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
function parseResourceId(value, flag) {
|
|
124
|
+
if (!value || !RESOURCE_ID_RE.test(value)) {
|
|
125
|
+
throw new UsageError(`${flag} needs ${flag === '--dataset' ? 'a dataset id' : 'an evaluator id'}.`, 2)
|
|
126
|
+
}
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseThreshold(value) {
|
|
131
|
+
if (!/^(?:0|[1-9]\d?|100)$/.test(value || '')) {
|
|
132
|
+
throw new UsageError(`--threshold must be a whole number from 0 to 100, got ${value || '(missing)'}.`, 2)
|
|
133
|
+
}
|
|
134
|
+
return Number(value)
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
function parseServer(value) {
|
|
110
138
|
let url
|
|
111
139
|
try {
|
|
@@ -137,11 +165,13 @@ function parseJsonPath(value) {
|
|
|
137
165
|
return value
|
|
138
166
|
}
|
|
139
167
|
|
|
140
|
-
function takeValue(argv, index, flag) {
|
|
168
|
+
function takeValue(argv, index, flag, allowSigned = false) {
|
|
141
169
|
const inline = argv[index].indexOf('=')
|
|
142
170
|
if (inline !== -1) return { value: argv[index].slice(inline + 1), next: index }
|
|
143
171
|
const value = argv[index + 1]
|
|
144
|
-
if (!value || value.startsWith('-'))
|
|
172
|
+
if (!value || (!allowSigned && value.startsWith('-'))) {
|
|
173
|
+
throw new UsageError(`${flag} needs a value. Run cuttlely --help.`, commandFailureCode(argv))
|
|
174
|
+
}
|
|
145
175
|
return { value, next: index + 1 }
|
|
146
176
|
}
|
|
147
177
|
|
|
@@ -159,7 +189,12 @@ export function parseArgs(argv) {
|
|
|
159
189
|
server: null,
|
|
160
190
|
timeoutSeconds: null,
|
|
161
191
|
jsonPath: null,
|
|
162
|
-
failOnChanged: false
|
|
192
|
+
failOnChanged: false,
|
|
193
|
+
datasetId: null,
|
|
194
|
+
evaluatorIds: [],
|
|
195
|
+
threshold: null,
|
|
196
|
+
gate: false,
|
|
197
|
+
scored: false
|
|
163
198
|
}
|
|
164
199
|
const words = []
|
|
165
200
|
const dockerFlags = []
|
|
@@ -212,6 +247,24 @@ export function parseArgs(argv) {
|
|
|
212
247
|
} else if (arg === '--fail-on-changed') {
|
|
213
248
|
opts.failOnChanged = true
|
|
214
249
|
checkFlags.push('--fail-on-changed')
|
|
250
|
+
} else if (arg === '--dataset' || arg.startsWith('--dataset=')) {
|
|
251
|
+
const taken = takeValue(argv, i, '--dataset')
|
|
252
|
+
opts.datasetId = taken.value
|
|
253
|
+
i = taken.next
|
|
254
|
+
checkFlags.push('--dataset')
|
|
255
|
+
} else if (arg === '--evaluator' || arg.startsWith('--evaluator=')) {
|
|
256
|
+
const taken = takeValue(argv, i, '--evaluator')
|
|
257
|
+
opts.evaluatorIds.push(taken.value)
|
|
258
|
+
i = taken.next
|
|
259
|
+
checkFlags.push('--evaluator')
|
|
260
|
+
} else if (arg === '--threshold' || arg.startsWith('--threshold=')) {
|
|
261
|
+
const taken = takeValue(argv, i, '--threshold', true)
|
|
262
|
+
opts.threshold = taken.value
|
|
263
|
+
i = taken.next
|
|
264
|
+
checkFlags.push('--threshold')
|
|
265
|
+
} else if (arg === '--gate') {
|
|
266
|
+
opts.gate = true
|
|
267
|
+
checkFlags.push('--gate')
|
|
215
268
|
} else if (arg.startsWith('-')) {
|
|
216
269
|
throw new UsageError(`Unknown option ${arg}. Run cuttlely --help.`, commandFailureCode(argv))
|
|
217
270
|
} else words.push(arg)
|
|
@@ -243,6 +296,40 @@ export function parseArgs(argv) {
|
|
|
243
296
|
}
|
|
244
297
|
// An empty --json= is still the flag. Skipping it would run the check and write nothing.
|
|
245
298
|
if (checkFlags.includes('--json')) opts.jsonPath = parseJsonPath(opts.jsonPath)
|
|
299
|
+
if (opts.gate && checkFlags.includes('--dataset')) {
|
|
300
|
+
throw new UsageError('--gate uses the saved test set. Do not pass --dataset with it.', checkExit)
|
|
301
|
+
}
|
|
302
|
+
if (opts.gate && (checkFlags.includes('--evaluator') || checkFlags.includes('--threshold'))) {
|
|
303
|
+
throw new UsageError(
|
|
304
|
+
'--gate uses the saved dataset, evaluators, and threshold. Do not pass --evaluator or --threshold with it.',
|
|
305
|
+
checkExit
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
const scoredFlag = ['--dataset', '--evaluator', '--threshold', '--gate'].some((flag) => checkFlags.includes(flag))
|
|
309
|
+
if (opts.failOnChanged && scoredFlag) {
|
|
310
|
+
throw new UsageError('--fail-on-changed cannot be combined with --dataset, --evaluator, --threshold, or --gate.', checkExit)
|
|
311
|
+
}
|
|
312
|
+
if (checkFlags.includes('--dataset')) opts.datasetId = parseResourceId(opts.datasetId, '--dataset')
|
|
313
|
+
if (checkFlags.includes('--evaluator')) {
|
|
314
|
+
const seen = new Set()
|
|
315
|
+
const unique = []
|
|
316
|
+
for (const id of opts.evaluatorIds) {
|
|
317
|
+
const parsed = parseResourceId(id, '--evaluator')
|
|
318
|
+
if (seen.has(parsed)) continue
|
|
319
|
+
seen.add(parsed)
|
|
320
|
+
unique.push(parsed)
|
|
321
|
+
}
|
|
322
|
+
opts.evaluatorIds = unique
|
|
323
|
+
}
|
|
324
|
+
if (checkFlags.includes('--threshold')) opts.threshold = parseThreshold(opts.threshold)
|
|
325
|
+
if (
|
|
326
|
+
!opts.gate &&
|
|
327
|
+
!checkFlags.includes('--dataset') &&
|
|
328
|
+
(checkFlags.includes('--evaluator') || checkFlags.includes('--threshold'))
|
|
329
|
+
) {
|
|
330
|
+
throw new UsageError('--evaluator and --threshold need --dataset.', checkExit)
|
|
331
|
+
}
|
|
332
|
+
opts.scored = Boolean(opts.gate || checkFlags.includes('--dataset'))
|
|
246
333
|
}
|
|
247
334
|
} else if (checkFlags.length) {
|
|
248
335
|
throw new UsageError(`Unknown option ${checkFlags[0]}. Run cuttlely --help.`)
|
|
@@ -576,7 +663,12 @@ export async function main(argv, io = {}) {
|
|
|
576
663
|
server: opts.server,
|
|
577
664
|
timeoutSeconds: opts.timeoutSeconds,
|
|
578
665
|
jsonPath: opts.jsonPath,
|
|
579
|
-
failOnChanged: opts.failOnChanged
|
|
666
|
+
failOnChanged: opts.failOnChanged,
|
|
667
|
+
scored: opts.scored,
|
|
668
|
+
gate: opts.gate,
|
|
669
|
+
datasetId: opts.datasetId,
|
|
670
|
+
evaluatorIds: opts.evaluatorIds,
|
|
671
|
+
threshold: opts.threshold
|
|
580
672
|
},
|
|
581
673
|
ctx
|
|
582
674
|
)
|