evals-lab 0.13.0 → 0.15.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/CHANGELOG.md +83 -0
- package/README.md +2 -2
- package/bin/run.js +4 -2
- package/lab/VERSION +1 -1
- package/lab/demo/CREDITS.md +14 -0
- package/lab/demo/datasets/demo-1.json +2 -2
- package/lab/demo/datasets/demo-2.json +2 -2
- package/lab/demo/datasets/documents-type.json +79 -0
- package/lab/demo/datasets/documents-valid.json +117 -0
- package/lab/demo/datasets/sentiment.json +2 -2
- package/lab/demo/datasets/weather-alerts.json +2 -2
- package/lab/demo/datasets/wordings.json +1 -1
- package/lab/demo/manifest.json +3 -3
- package/lab/demo/pipelines/demo-1.json +17 -14
- package/lab/demo/pipelines/demo-2.json +17 -14
- package/lab/demo/pipelines/documents.json +101 -0
- package/lab/demo/pipelines/sentiment.json +4 -4
- package/lab/demo/pipelines/weather-alerts.json +9 -5
- package/lab/demo/pipelines/wordings.json +3 -3
- package/lab/demo/sources/Demo 1/source.json +1 -1
- package/lab/demo/sources/Demo 2/source.json +1 -1
- package/lab/demo/sources/documents/invoice.pdf +65 -0
- package/lab/demo/sources/documents/letter.pdf +63 -0
- package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
- package/lab/demo/sources/documents/receipt.pdf +62 -0
- package/lab/demo/sources/documents/report.pdf +59 -0
- package/lab/demo/sources/documents/source.json +3 -0
- package/lab/demo/sources/documents/truncated.pdf +19 -0
- package/lab/demo/sources/sentiment/source.json +1 -1
- package/lab/demo/sources/weather-alerts/source.json +1 -1
- package/lab/evals-core.mjs +408 -160
- package/lab/flows/record.mjs +5 -4
- package/lab/kinds/list.mjs +337 -182
- package/lab/run-evals.js +3 -1
- package/lab/server.py +661 -108
- package/lab/web/dist/assets/gallery-Cy8oycht.js +5 -0
- package/lab/web/dist/assets/gallery-eVJoaK-6.css +1 -0
- package/lab/web/dist/assets/main-B-LGmhPu.css +1 -0
- package/lab/web/dist/assets/main-wqNvXpWp.js +15 -0
- package/lab/web/dist/assets/tokens-CWVzmtid.css +1 -0
- package/lab/web/dist/assets/tokens-mCS6IZGh.js +64 -0
- package/lab/web/dist/gallery.html +4 -4
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-B_-TH0F-.css +0 -1
- package/lab/web/dist/assets/gallery-DpaqOg9Z.js +0 -5
- package/lab/web/dist/assets/main-BSw-jyYD.css +0 -1
- package/lab/web/dist/assets/main-CrB8wPZn.js +0 -17
- package/lab/web/dist/assets/tokens-CrjDMCbz.js +0 -60
- package/lab/web/dist/assets/tokens-DDOONQon.css +0 -1
package/lab/kinds/list.mjs
CHANGED
|
@@ -87,7 +87,7 @@ const inRange = (b ) => (v ) =>
|
|
|
87
87
|
/** The stateless predicates an item matcher takes, on the item alone. */
|
|
88
88
|
export const MATCHER_KEYS = ["terms", "regex", "length", "words", "tokens", "digitShare"] ;
|
|
89
89
|
|
|
90
|
-
/** Every condition a
|
|
90
|
+
/** Every condition a Remove of the items that match may hold: the
|
|
91
91
|
stateless matcher keys, and the two that read the item in context --
|
|
92
92
|
`duplicate` (a case-insensitive repeat of one kept earlier in this pass)
|
|
93
93
|
and `echoes` (made only of the stage instruction's words), #405. */
|
|
@@ -225,29 +225,25 @@ export function tryExamples(rule , scope )
|
|
|
225
225
|
// ---- filters: the generic reply filter (issue #335) ----------------------------
|
|
226
226
|
//
|
|
227
227
|
// A Filter is the reusable unit #334 introduces: it supersedes the inline
|
|
228
|
-
// ItemRule a Drop items or Reject the answer modifier
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// one, and their version live in evals-core.ts, as DatasetBody and the
|
|
236
|
-
// eval-group link do. Nothing here replaces the Drop items / Reject modifiers:
|
|
237
|
-
// Runs wires a filter set into Responses and migrates the inline rules in a
|
|
238
|
-
// later phase (#339), so both base types and the ItemRule shape stand.
|
|
228
|
+
// ItemRule a Drop items or Reject the answer modifier carried. Where a rule
|
|
229
|
+
// was a bare matcher with examples, a filter has a base TYPE from a registry
|
|
230
|
+
// (not an enum) -- one of seven verbs (#615) -- generic conditions reusing the
|
|
231
|
+
// matcher above (ANDed), Options, and Tests that are its own checks. The verbs
|
|
232
|
+
// evaluate here, beside the matcher they are built from; the Library-stored
|
|
233
|
+
// filter-set body, the pipeline link that carries one, and their version live
|
|
234
|
+
// in evals-core.ts, as DatasetBody and the eval-group link do.
|
|
239
235
|
|
|
240
236
|
/** How a filter test splits its sample reply into items: by the named
|
|
241
237
|
delimiter and always by newline, as a run's parse does. "none" keeps the
|
|
242
|
-
reply whole
|
|
238
|
+
reply whole: a test of the filter on a reply's text. */
|
|
243
239
|
export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"] ;
|
|
244
240
|
|
|
245
241
|
|
|
246
242
|
/** A sample reply and what the filter should leave of it: the editor splits
|
|
247
243
|
the sample by `separator`, applies the filter, and shows whether the result
|
|
248
|
-
reads as `expectedOutput
|
|
249
|
-
|
|
250
|
-
|
|
244
|
+
reads as `expectedOutput`: the items or text it leaves, or for a Reject,
|
|
245
|
+
blank when the reply should be rejected and anything else when it should
|
|
246
|
+
pass. */
|
|
251
247
|
|
|
252
248
|
|
|
253
249
|
|
|
@@ -261,31 +257,35 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
|
|
|
261
257
|
|
|
262
258
|
|
|
263
259
|
|
|
264
|
-
/** A filter (issue #335, #
|
|
265
|
-
|
|
266
|
-
an optional `op` carrying the
|
|
267
|
-
|
|
268
|
-
its name, as an ItemRule's was. */
|
|
260
|
+
/** A filter (issue #335, #615): a base TYPE fixed at creation -- one of the
|
|
261
|
+
FILTER_TYPES verbs -- generic conditions in `match` (the matcher's keys
|
|
262
|
+
for its type, ANDed), an optional `op` carrying the verb's own settings,
|
|
263
|
+
Options and Tests. `id` is its name, as an ItemRule's was. */
|
|
269
264
|
|
|
270
265
|
|
|
271
266
|
|
|
272
267
|
|
|
273
268
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
|
|
278
272
|
|
|
279
273
|
|
|
280
274
|
|
|
281
275
|
|
|
282
276
|
|
|
283
|
-
/** What a filter
|
|
284
|
-
|
|
285
|
-
|
|
277
|
+
/** What a filter runs on (#615): the reply's `text` -- above a format that
|
|
278
|
+
reads items, or anywhere in a job whose format reads text -- or each of
|
|
279
|
+
its `items`, below a format that reads them. A text reply is a list of one
|
|
280
|
+
item, so every verb means the same on either. */
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
/** What a filter works over: the text the reply came as (which a filter on
|
|
284
|
+
the text rewrites, and the items are parsed from), the items parsed from
|
|
285
|
+
it, and the stage's instruction a filter may read (an item that echoes it). */
|
|
286
286
|
|
|
287
287
|
|
|
288
|
-
/** What applying a filter or a set left: the reply text (a
|
|
288
|
+
/** What applying a filter or a set left: the reply text (a filter on the text
|
|
289
289
|
changes it), the items kept, what was dropped and by which filter, and --
|
|
290
290
|
the first filter that rejected -- the reply's rejection (null when none). */
|
|
291
291
|
|
|
@@ -293,92 +293,198 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
|
|
|
293
293
|
|
|
294
294
|
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
|
|
297
297
|
|
|
298
298
|
|
|
299
299
|
|
|
300
|
-
/** A base filter type (#334's decision: a registry, not an enum
|
|
301
|
-
asks the entry by `filter.type`; nothing
|
|
300
|
+
/** A base filter type: a verb (#334's decision: a registry, not an enum; #615
|
|
301
|
+
made them seven verbs). A reader asks the entry by `filter.type`; nothing
|
|
302
|
+
branches on the id. */
|
|
302
303
|
|
|
303
|
-
|
|
304
|
+
|
|
304
305
|
|
|
305
|
-
|
|
306
|
+
|
|
306
307
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
|
|
310
309
|
|
|
311
|
-
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
|
|
312
314
|
|
|
313
315
|
|
|
314
316
|
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
|
|
315
320
|
|
|
316
321
|
|
|
317
322
|
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
|
|
318
334
|
|
|
319
335
|
|
|
320
336
|
|
|
321
337
|
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
322
341
|
|
|
323
|
-
|
|
324
|
-
|
|
325
342
|
|
|
326
343
|
|
|
327
|
-
/**
|
|
328
|
-
`blank
|
|
329
|
-
-- a
|
|
330
|
-
|
|
344
|
+
/** Whether a reply whose text is [body] and items are [items] meets Reject's
|
|
345
|
+
conditions [m]: `blank` (the text is blank), `regex` (searched anywhere in
|
|
346
|
+
the text, case-insensitive -- a reject looks for a phrase, not a whole
|
|
347
|
+
string), `itemCount`, and the `share` of the items `items` matches. Every
|
|
348
|
+
one given must hold; one that states none never fires. */
|
|
349
|
+
function rejectFires(m , body , items ) {
|
|
331
350
|
const conds = [];
|
|
332
|
-
if (m.blank === true) conds.push(!
|
|
333
|
-
if (typeof m.regex === "string") {
|
|
351
|
+
if (m.blank === true) conds.push(!body.trim());
|
|
352
|
+
if (typeof m.regex === "string") { let re ; try { re = new RegExp(m.regex, "i"); } catch { re = null; } conds.push(!!re && re.test(body)); }
|
|
353
|
+
if (m.itemCount && typeof m.itemCount === "object") conds.push(inRange(m.itemCount )(items.length));
|
|
354
|
+
if (m.share && typeof m.share === "object") {
|
|
355
|
+
const test = matcher(m.items );
|
|
356
|
+
// A share with nothing to measure cannot fire.
|
|
357
|
+
conds.push(!!test && inRange(m.share )(items.length ? items.filter(t => test(t)).length / items.length : 0));
|
|
358
|
+
}
|
|
334
359
|
return conds.length > 0 && conds.every(Boolean);
|
|
335
360
|
}
|
|
336
361
|
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (op?.kind === "preamble") return removePreamble;
|
|
352
|
-
if (op?.kind === "replace") {
|
|
353
|
-
const re = new RegExp(String(op.pattern ?? ""), "g");
|
|
354
|
-
const to = typeof op.replacement === "string" ? op.replacement : "";
|
|
355
|
-
return text => text.replace(re, to);
|
|
362
|
+
/** A reply's text as the one item it is (#615): none when it is blank. */
|
|
363
|
+
const oneItem = (body ) => (body.trim() ? [body.trim()] : []);
|
|
364
|
+
|
|
365
|
+
/** A verb that changes one piece of text at a time, applied to [reply]: to
|
|
366
|
+
its text, or to each item its `match` limits it to (none: every one). Run
|
|
367
|
+
on the text, a [change] that leaves nothing (null) rejects the reply, as an
|
|
368
|
+
Extract that matched nothing always has; run on the items, the item goes,
|
|
369
|
+
as does one it leaves blank (as Tidy always dropped one it emptied). */
|
|
370
|
+
function eachPiece(filter , reply , on , change ) {
|
|
371
|
+
const test = matcher(filter.match);
|
|
372
|
+
if (on === "text") {
|
|
373
|
+
if (test && !test(reply.body)) return outcome(reply);
|
|
374
|
+
const body = change(reply.body);
|
|
375
|
+
return { body: body ?? reply.body, items: reply.items, dropped: [], reject: body == null ? filter.id : null };
|
|
356
376
|
}
|
|
357
|
-
|
|
377
|
+
const items = [], dropped = [];
|
|
378
|
+
for (const t of reply.items) {
|
|
379
|
+
const next = !test || test(t) ? change(t) : t;
|
|
380
|
+
if (next == null) dropped.push({ item: t, by: filter.id });
|
|
381
|
+
else if (next) items.push(next);
|
|
382
|
+
}
|
|
383
|
+
return { body: reply.body, items, dropped, reject: null };
|
|
358
384
|
}
|
|
359
385
|
|
|
360
|
-
/**
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
386
|
+
/** One item as Trim with bullets leaves it (tidy); a text, each line so. */
|
|
387
|
+
const tidyLines = (text ) => text.split("\n").map(tidy).filter(Boolean).join("\n");
|
|
388
|
+
|
|
389
|
+
/** What Remove takes out of a piece of text, by its `kind`: a thinking
|
|
390
|
+
block, a preamble, code fences. "items" is Remove's own reading of its
|
|
391
|
+
matcher, not a rewrite. */
|
|
392
|
+
const REMOVALS = {
|
|
393
|
+
thinking: removeThinking,
|
|
394
|
+
preamble: removePreamble,
|
|
395
|
+
// A fence's own lines go -- the opening one with its language, and the
|
|
396
|
+
// closing one -- wherever they sit, so a reply fenced whole or a fence
|
|
397
|
+
// after a line of preamble both read as what was inside.
|
|
398
|
+
fences: text => text.replace(FENCE_LINE, ""),
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const FENCE_LINE = /^[ \t]*```[^`\n]*(?:\n|$)/gm;
|
|
402
|
+
|
|
403
|
+
/** Keep first's units. */
|
|
404
|
+
const KEEP_UNITS = [{ value: "items", label: "Items" }, { value: "lines", label: "Lines" }, { value: "chars", label: "Characters" }];
|
|
405
|
+
|
|
406
|
+
/** Why a pattern does not compile, as a sentence, or none. */
|
|
407
|
+
const patternProblems = (pattern , at ) => {
|
|
408
|
+
if (typeof pattern !== "string" || !pattern) return [`${at} needs a pattern`];
|
|
409
|
+
try { new RegExp(pattern); return []; } catch { return [`${at}'s pattern does not compile`]; }
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const caseProblems = (op , at ) =>
|
|
413
|
+
(CASE_MODES.includes(op?.mode ) ? [] : [`${at}'s case needs a mode`]);
|
|
414
|
+
|
|
415
|
+
/** The lab's base filter types: seven verbs (#615), A to Z. Generic code asks
|
|
416
|
+
this registry by `filter.type`; it never names an id. Where a filter runs
|
|
417
|
+
-- on the text or on each item -- is its place in the list, around the
|
|
418
|
+
job's Response Format, never its type. */
|
|
364
419
|
export const FILTER_TYPES = {
|
|
365
|
-
"
|
|
366
|
-
label: "
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
apply: (filter, reply) => (
|
|
420
|
+
"change-case": {
|
|
421
|
+
label: "Change case", about: "Lower, upper, sentence or title case.", icon: "a-large-small",
|
|
422
|
+
matches: "limit", conditions: MATCHER_KEYS,
|
|
423
|
+
defaultOp: () => ({ mode: "LOWER" }),
|
|
424
|
+
opProblems: (f, at) => [...caseProblems(f.op, at), ...extraOpKeys(f.op, ["mode"], at)],
|
|
425
|
+
apply: (filter, reply, on) => eachPiece(filter, reply, on, t => applyCase(t, filter.op?.mode )),
|
|
426
|
+
},
|
|
427
|
+
"extract": {
|
|
428
|
+
label: "Extract", about: "The first match of a pattern.", icon: "scan-search",
|
|
429
|
+
conditions: [], rawReply: () => true,
|
|
430
|
+
defaultOp: () => ({ pattern: "" }),
|
|
431
|
+
opProblems: (f, at) => [...patternProblems(f.op?.pattern, at), ...extraOpKeys(f.op, ["pattern"], at)],
|
|
432
|
+
apply(filter, reply, on) {
|
|
433
|
+
// The first match, or its first group if it has one; none, nothing left.
|
|
434
|
+
const re = new RegExp(String(filter.op?.pattern ?? ""));
|
|
435
|
+
return eachPiece(filter, reply, on, t => { const m = re.exec(t); return m ? (m.length > 1 ? m[1] ?? "" : m[0]) : null; });
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
"keep-first": {
|
|
439
|
+
label: "Keep first", about: "The first N items, lines or characters.", icon: "text-search",
|
|
440
|
+
conditions: [], choices: { unit: KEEP_UNITS },
|
|
441
|
+
defaultOp: () => ({ n: 10, unit: "items" }),
|
|
442
|
+
opProblems: (f, at) => [
|
|
443
|
+
...(Number.isInteger(f.op?.n) && (f.op .n ) >= 1 ? [] : [`${at} needs a whole number of 1 or more`]),
|
|
444
|
+
...(KEEP_UNITS.some(u => u.value === f.op?.unit) ? [] : [`${at} keeps items, lines or characters`]),
|
|
445
|
+
...extraOpKeys(f.op, ["n", "unit"], at)],
|
|
446
|
+
apply(filter, reply, on) {
|
|
447
|
+
const n = filter.op?.n ;
|
|
448
|
+
if (filter.op?.unit === "items") {
|
|
449
|
+
// A text is one item, which the first N always keep.
|
|
450
|
+
if (on === "text") return outcome(reply);
|
|
451
|
+
return { body: reply.body, items: reply.items.slice(0, n), dropped: reply.items.slice(n).map(t => ({ item: t, by: pastN(n) })), reject: null };
|
|
452
|
+
}
|
|
453
|
+
return eachPiece(filter, reply, on, filter.op?.unit === "chars" ? t => t.slice(0, n) : t => t.split("\n").slice(0, n).join("\n"));
|
|
454
|
+
},
|
|
371
455
|
},
|
|
372
|
-
"reject
|
|
373
|
-
label: "Reject
|
|
374
|
-
|
|
456
|
+
"reject": {
|
|
457
|
+
label: "Reject", about: "Discard the reply when it matches its conditions.", icon: "trash",
|
|
458
|
+
matches: "reply", conditions: ["blank", "regex", "itemCount", "share", "items"], rejects: true,
|
|
459
|
+
apply: (filter, reply, on) => ({ body: reply.body, items: reply.items, dropped: [],
|
|
460
|
+
reject: rejectFires(filter.match, reply.body, on === "text" ? oneItem(reply.body) : reply.items) ? filter.id : null }),
|
|
375
461
|
},
|
|
376
|
-
"
|
|
377
|
-
label: "
|
|
378
|
-
|
|
462
|
+
"remove": {
|
|
463
|
+
label: "Remove", about: "A thinking block, a preamble, code fences, or the items that match.", icon: "eraser",
|
|
464
|
+
matches: "item", conditions: ITEM_CONDITIONS,
|
|
465
|
+
kinds: [
|
|
466
|
+
{ kind: "thinking", label: "Thinking block", about: "A <think>…</think> block" },
|
|
467
|
+
{ kind: "preamble", label: "Preamble", about: "Everything up to the first colon" },
|
|
468
|
+
{ kind: "fences", label: "Code fences", about: "Each ``` line, keeping what it fences" },
|
|
469
|
+
{ kind: "items", label: "Items that match" },
|
|
470
|
+
],
|
|
471
|
+
defaultOp: (kind = "items") => ({ kind }),
|
|
472
|
+
matchesWith: op => op?.kind === "items",
|
|
473
|
+
rawReply: f => f.op?.kind !== "items",
|
|
474
|
+
opProblems(f, at) {
|
|
475
|
+
const kind = f.op?.kind ;
|
|
476
|
+
if (kind !== "items" && !(kind in REMOVALS)) return [`${at} has nothing to remove`];
|
|
477
|
+
return [...extraOpKeys(f.op, ["kind"], at),
|
|
478
|
+
...(kind !== "items" && Object.keys(f.match ?? {}).length ? [`${at} takes no conditions`] : [])];
|
|
479
|
+
},
|
|
480
|
+
apply(filter, reply, on) {
|
|
481
|
+
const kind = filter.op?.kind ;
|
|
482
|
+
if (kind !== "items") return eachPiece({ ...filter, match: {} }, reply, on, REMOVALS[kind] ?? (t => t));
|
|
379
483
|
const test = matcher(filter.match);
|
|
380
484
|
if (!test) return outcome(reply);
|
|
381
485
|
const said = reply.instruction && reply.instruction.trim() ? new Set(words(reply.instruction)) : new Set ();
|
|
486
|
+
// A text is one item: matched, nothing of it is left.
|
|
487
|
+
if (on === "text") return test(reply.body, { seen: new Set(), said }) ? { ...outcome(reply), body: "" } : outcome(reply);
|
|
382
488
|
const seen = new Set ();
|
|
383
489
|
const items = [], dropped = [];
|
|
384
490
|
for (const t of reply.items) {
|
|
@@ -388,58 +494,61 @@ export const FILTER_TYPES = {
|
|
|
388
494
|
return { body: reply.body, items, dropped, reject: null };
|
|
389
495
|
},
|
|
390
496
|
},
|
|
391
|
-
"
|
|
392
|
-
label: "
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
497
|
+
"replace": {
|
|
498
|
+
label: "Replace", about: "Text or a pattern, with other text.", icon: "replace",
|
|
499
|
+
conditions: [],
|
|
500
|
+
kinds: [
|
|
501
|
+
{ kind: "text", label: "Text", about: "Every occurrence, matched exactly" },
|
|
502
|
+
{ kind: "pattern", label: "Pattern", about: "Every match of a regular expression" },
|
|
503
|
+
],
|
|
504
|
+
defaultOp: (kind = "text") => (kind === "pattern" ? { kind, pattern: "", replacement: "" } : { kind, find: "", replacement: "" }),
|
|
505
|
+
opProblems(f, at) {
|
|
506
|
+
const op = f.op;
|
|
507
|
+
if (op?.kind === "text") {
|
|
508
|
+
return [...(typeof op.find === "string" && op.find ? [] : [`${at} needs text to find`]),
|
|
509
|
+
...extraOpKeys(op, ["kind", "find", "replacement"], at)];
|
|
510
|
+
}
|
|
511
|
+
if (op?.kind === "pattern") return [...patternProblems(op.pattern, at), ...extraOpKeys(op, ["kind", "pattern", "replacement"], at)];
|
|
512
|
+
return [`${at} replaces text or a pattern`];
|
|
402
513
|
},
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
514
|
+
apply(filter, reply, on) {
|
|
515
|
+
const op = filter.op ?? {};
|
|
516
|
+
const to = typeof op.replacement === "string" ? op.replacement : "";
|
|
517
|
+
if (op.kind === "pattern") {
|
|
518
|
+
const re = new RegExp(String(op.pattern ?? ""), "g");
|
|
519
|
+
return eachPiece(filter, reply, on, t => t.replace(re, to));
|
|
520
|
+
}
|
|
521
|
+
const find = String(op.find ?? "");
|
|
522
|
+
return eachPiece(filter, reply, on, t => (find ? t.split(find).join(to) : t));
|
|
411
523
|
},
|
|
412
524
|
},
|
|
413
|
-
"
|
|
414
|
-
label: "
|
|
415
|
-
|
|
525
|
+
"trim": {
|
|
526
|
+
label: "Trim", about: "Whitespace and blank lines, and bullets and numbering if you choose.", icon: "scissors",
|
|
527
|
+
matches: "limit", conditions: MATCHER_KEYS,
|
|
528
|
+
defaultOp: () => ({ bullets: false }),
|
|
529
|
+
opProblems: (f, at) => [...(typeof f.op?.bullets === "boolean" ? [] : [`${at}'s bullets has to be true or false`]),
|
|
530
|
+
...extraOpKeys(f.op, ["bullets"], at)],
|
|
531
|
+
// With bullets, an item is tidied -- its bullet, numbering, stray quotes
|
|
532
|
+
// and stops gone, its spaces collapsed -- and a text each of its lines.
|
|
533
|
+
apply: (filter, reply, on) => eachPiece(filter, reply, on,
|
|
534
|
+
filter.op?.bullets ? (on === "text" ? tidyLines : tidy) : t => t.trim()),
|
|
416
535
|
},
|
|
417
536
|
};
|
|
418
537
|
|
|
419
538
|
/** The reply unchanged -- a filter that matches nothing. */
|
|
420
539
|
const outcome = (reply ) => ({ body: reply.body, items: reply.items, dropped: [], reject: null });
|
|
421
540
|
|
|
422
|
-
/** Why [op]'s `kind` is not one of [kinds] (and its extra keys beyond that
|
|
423
|
-
kind's), one sentence each. [more] adds a kind's own checks. */
|
|
424
|
-
function opKindProblems(op , at ,
|
|
425
|
-
kinds , more ) {
|
|
426
|
-
if (!op || typeof op !== "object" || Array.isArray(op)) return [`${at} has no operation`];
|
|
427
|
-
const kind = op.kind ;
|
|
428
|
-
if (typeof kind !== "string" || !(kind in kinds)) return [`${at}'s operation is not one this filter has`];
|
|
429
|
-
return [...extraOpKeys(op, ["kind", ...kinds[kind] ], at), ...(more ? more(op) : [])];
|
|
430
|
-
}
|
|
431
|
-
|
|
432
541
|
/** Why [op] holds a key that is not [allowed], one sentence each. */
|
|
433
542
|
function extraOpKeys(op , allowed , at ) {
|
|
434
543
|
return op ? Object.keys(op).filter(k => !allowed.includes(k)).map(k => `${at} has "${k}", which is not one of its settings`) : [];
|
|
435
544
|
}
|
|
436
545
|
|
|
437
|
-
/** Apply one filter to [reply]
|
|
438
|
-
the reply (a filter that rejects stops at the
|
|
439
|
-
`ignoreFailures` leaves the reply as it was if the
|
|
440
|
-
condition or pattern that cannot be read -- rather than
|
|
441
|
-
Pure. */
|
|
442
|
-
export function applyFilter(filter , reply ) {
|
|
546
|
+
/** Apply one filter to [reply] [on] its text or its items, honouring its
|
|
547
|
+
Options: `repeat` passes over the reply (a filter that rejects stops at the
|
|
548
|
+
first that fires), and `ignoreFailures` leaves the reply as it was if the
|
|
549
|
+
filter errors -- a condition or pattern that cannot be read -- rather than
|
|
550
|
+
failing the run. Pure. */
|
|
551
|
+
export function applyFilter(filter , reply , on = "items") {
|
|
443
552
|
const entry = FILTER_TYPES[filter.type];
|
|
444
553
|
if (!entry) return outcome(reply);
|
|
445
554
|
const passes = Number.isInteger(filter.options?.repeat) && filter.options.repeat > 0 ? filter.options.repeat : 1;
|
|
@@ -449,7 +558,7 @@ export function applyFilter(filter , reply ) {
|
|
|
449
558
|
let reject = null;
|
|
450
559
|
for (let pass = 0; pass < passes && reject == null; pass++) {
|
|
451
560
|
try {
|
|
452
|
-
const out = entry.apply(filter, { body, items, instruction });
|
|
561
|
+
const out = entry.apply(filter, { body, items, instruction }, on);
|
|
453
562
|
body = out.body;
|
|
454
563
|
items = out.items;
|
|
455
564
|
dropped.push(...out.dropped);
|
|
@@ -464,21 +573,17 @@ export function applyFilter(filter , reply ) {
|
|
|
464
573
|
return { body, items, dropped, reject };
|
|
465
574
|
}
|
|
466
575
|
|
|
467
|
-
/** Apply
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
its text filters on the raw reply and the rest on the items. Pure. */
|
|
473
|
-
export function applyFilterSet(filters , reply , stage ) {
|
|
576
|
+
/** Apply [filters] to [reply] in order, [on] its text or its items (#615:
|
|
577
|
+
where they sit around the job's Response Format, never their type): each
|
|
578
|
+
reshapes the text or narrows the items the next reads, and the first that
|
|
579
|
+
rejects gates the answer and stops the rest. Pure. */
|
|
580
|
+
export function applyFilterSet(filters , reply , on = "items") {
|
|
474
581
|
const instruction = reply.instruction ?? "";
|
|
475
582
|
let body = reply.body, items = reply.items;
|
|
476
583
|
const dropped = [], rewrote = [];
|
|
477
584
|
let emptied ;
|
|
478
585
|
for (const filter of filters) {
|
|
479
|
-
const
|
|
480
|
-
if (stage && scope && (scope === "text") !== (stage === "text")) continue;
|
|
481
|
-
const out = applyFilter(filter, { body, items, instruction });
|
|
586
|
+
const out = applyFilter(filter, { body, items, instruction }, on);
|
|
482
587
|
if (out.body !== body) rewrote.push(filter.id);
|
|
483
588
|
body = out.body;
|
|
484
589
|
if (items.length && !out.items.length) emptied = filter.id;
|
|
@@ -504,24 +609,26 @@ export function splitSample(sample , separator )
|
|
|
504
609
|
return parts.map(s => s.trim()).filter(Boolean);
|
|
505
610
|
}
|
|
506
611
|
|
|
507
|
-
/** Each of [filter]'s tests, tried against the filter alone
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
is rejected exactly if the expected output is
|
|
511
|
-
when the text it leaves reads as the expected text
|
|
512
|
-
|
|
612
|
+
/** Each of [filter]'s tests, tried against the filter alone. A test's
|
|
613
|
+
separator says what its sample is: "none" a reply's text, which the filter
|
|
614
|
+
runs on as text; any other, items, which it runs on each of. A rejecting
|
|
615
|
+
filter passes when the reply is rejected exactly if the expected output is
|
|
616
|
+
blank; on the text, when the text it leaves reads as the expected text (or,
|
|
617
|
+
where it rejects, the expected output is blank); on items, when the items
|
|
618
|
+
it keeps read as the expected items. */
|
|
513
619
|
export function tryFilter(filter ) {
|
|
514
620
|
const entry = FILTER_TYPES[filter.type];
|
|
515
|
-
const scope = entry?.scope ?? "item";
|
|
516
621
|
return (filter.tests || []).map(test => {
|
|
517
|
-
|
|
518
|
-
const items =
|
|
519
|
-
const out = applyFilter(filter, { body: test.sampleInput, items, instruction: "" });
|
|
622
|
+
const on = test.separator === "none" ? "text" : "items";
|
|
623
|
+
const items = on === "text" ? [] : splitSample(test.sampleInput, test.separator);
|
|
624
|
+
const out = applyFilter(filter, { body: test.sampleInput, items, instruction: "" }, on);
|
|
520
625
|
if (entry?.rejects) {
|
|
521
626
|
const rejected = out.reject != null;
|
|
522
|
-
return { test, ok: rejected === !test.expectedOutput.trim(), got: rejected ? "rejected" : (
|
|
627
|
+
return { test, ok: rejected === !test.expectedOutput.trim(), got: rejected ? "rejected" : (on === "text" ? out.body : out.items.join(", ")) };
|
|
523
628
|
}
|
|
524
|
-
if (
|
|
629
|
+
if (on === "text") {
|
|
630
|
+
// A filter on the text that rejects (Extract, matching nothing) leaves nothing.
|
|
631
|
+
if (out.reject != null) return { test, ok: !test.expectedOutput.trim(), got: "rejected" };
|
|
525
632
|
const got = out.body.trim();
|
|
526
633
|
return { test, ok: got === test.expectedOutput.trim(), got };
|
|
527
634
|
}
|
|
@@ -558,7 +665,7 @@ const isObj = (v ) => !!v && typeof v ===
|
|
|
558
665
|
|
|
559
666
|
/** Why [filters] are not a filter list the lab reads, one sentence each: each
|
|
560
667
|
has a name, no name twice, a base type it has, whole-number repeat of 1 or
|
|
561
|
-
more, a boolean ignore-failures, conditions its type
|
|
668
|
+
more, a boolean ignore-failures, conditions its type takes, and an
|
|
562
669
|
operation its type takes (or none, for a type with none). */
|
|
563
670
|
export function filterProblems(filters , at ) {
|
|
564
671
|
if (!Array.isArray(filters)) return [`${at}: filters has to be a list`];
|
|
@@ -577,25 +684,24 @@ export function filterProblems(filters , at ) {
|
|
|
577
684
|
if (typeof o.ignoreFailures !== "boolean") bad.push(`${at}: ${id}'s ignore failures has to be true or false`);
|
|
578
685
|
}
|
|
579
686
|
bad.push(...filterMatchProblems(f.match, entry.conditions, at, id));
|
|
580
|
-
if (entry.opProblems)
|
|
581
|
-
|
|
687
|
+
if (entry.opProblems) {
|
|
688
|
+
bad.push(...(isObj(f.op) ? entry.opProblems(f, `${at}: ${id}`) : [`${at}: ${id} has no operation`]));
|
|
689
|
+
} else if (f.op != null) bad.push(`${at}: ${id} takes no operation`);
|
|
582
690
|
}
|
|
583
691
|
return bad;
|
|
584
692
|
}
|
|
585
693
|
|
|
586
694
|
/** The filter an inline ItemRule becomes (issue #335): its name, why and
|
|
587
|
-
matcher kept as they are, its
|
|
588
|
-
|
|
589
|
-
its Options the defaults, and its examples its tests
|
|
590
|
-
becomes a sample the filter should empty
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
export function filterFromItemRule(rule , type ) {
|
|
596
|
-
const scope = FILTER_TYPES[type]?.scope ?? "item";
|
|
695
|
+
matcher kept as they are, its verb the one the modifier carrying it fixed
|
|
696
|
+
-- Remove (its items that match) for Drop items, Reject for Reject the
|
|
697
|
+
answer -- its Options the defaults, and its examples its tests: a caught
|
|
698
|
+
example becomes a sample the filter should empty or reject, a kept example
|
|
699
|
+
one it should leave. Pure: the match is carried unchanged, so the filter
|
|
700
|
+
cleans a reply exactly as the rule did. The only reader of the old shape,
|
|
701
|
+
for Runs to migrate its inline rules (#339). */
|
|
702
|
+
export function filterFromItemRule(rule , type ) {
|
|
597
703
|
const ex = rule.examples || {};
|
|
598
|
-
const caught =
|
|
704
|
+
const caught = type === "remove" ? ex.drops || [] : ex.rejects || [];
|
|
599
705
|
const tests = [
|
|
600
706
|
...caught.map((s) => ({ separator: "comma", sampleInput: s, expectedOutput: "" })),
|
|
601
707
|
...(ex.keeps || []).map((s) => ({ separator: "comma", sampleInput: s, expectedOutput: s })),
|
|
@@ -604,11 +710,59 @@ export function filterFromItemRule(rule , type ) {
|
|
|
604
710
|
id: rule.id, type,
|
|
605
711
|
...(rule.why != null ? { why: rule.why } : {}),
|
|
606
712
|
match: rule.match ? { ...rule.match } : {},
|
|
713
|
+
...(type === "remove" ? { op: { kind: "items" } } : {}),
|
|
607
714
|
options: { ignoreFailures: false, repeat: 1 },
|
|
608
715
|
tests,
|
|
609
716
|
};
|
|
610
717
|
}
|
|
611
718
|
|
|
719
|
+
/** A version-3 filter's types (#405's six), and whether each read the
|
|
720
|
+
reply's text (Pre-format, or after a format that reads text) or its items
|
|
721
|
+
(after a format that reads them). Only the upgrade to version 4 reads them. */
|
|
722
|
+
export const V3_TEXT_TYPES = ["rewrite-text", "reject-text"];
|
|
723
|
+
|
|
724
|
+
/** A version-3 filter as the version-4 verb that reads a reply exactly as it
|
|
725
|
+
did (#615): Rewrite text's operations each their verb (Remove thinking /
|
|
726
|
+
preamble / code fences a Remove, Replace text / pattern a Replace, Trim,
|
|
727
|
+
Change case, Keep first and Extract their own), Reject text and Reject
|
|
728
|
+
reply a Reject, Drop item a Remove of the items that match, Rewrite item's
|
|
729
|
+
Tidy a Trim with bullets and its Case a Change case (each keeping the items
|
|
730
|
+
it limits to), and Keep first a Keep first of items. A text filter's tests
|
|
731
|
+
read the sample whole ("none"), as a text filter's always did. A filter of
|
|
732
|
+
no version-3 type comes back as it was. Pure; server.py's filter_v4 is its
|
|
733
|
+
twin. */
|
|
734
|
+
export function filterV4(f ) {
|
|
735
|
+
if (!isObj(f)) return f;
|
|
736
|
+
const op = isObj(f.op) ? f.op : {};
|
|
737
|
+
const as = (type , next ) => {
|
|
738
|
+
const { op: _op, ...rest } = f;
|
|
739
|
+
const out = { ...rest, type, ...next } ;
|
|
740
|
+
if (V3_TEXT_TYPES.includes(f.type) && Array.isArray(f.tests)) {
|
|
741
|
+
out.tests = f.tests.map(t => (isObj(t) ? { ...t, separator: "none" } : t));
|
|
742
|
+
}
|
|
743
|
+
return out;
|
|
744
|
+
};
|
|
745
|
+
switch (f.type) {
|
|
746
|
+
case "rewrite-text":
|
|
747
|
+
switch (op.kind) {
|
|
748
|
+
case "thinking": case "preamble": case "fences": return as("remove", { op: { kind: op.kind } });
|
|
749
|
+
case "trim": return as("trim", { op: { bullets: false } });
|
|
750
|
+
case "literal": return as("replace", { op: { kind: "text", find: op.find, replacement: op.replacement ?? "" } });
|
|
751
|
+
case "replace": return as("replace", { op: { kind: "pattern", pattern: op.pattern, replacement: op.replacement ?? "" } });
|
|
752
|
+
case "case": return as("change-case", { op: { mode: op.mode } });
|
|
753
|
+
case "first": return as("keep-first", { op: { n: op.n, unit: op.unit } });
|
|
754
|
+
case "extract": return as("extract", { op: { pattern: op.pattern } });
|
|
755
|
+
default: return f;
|
|
756
|
+
}
|
|
757
|
+
case "reject-text": case "reject-reply": return as("reject", {});
|
|
758
|
+
case "drop-item": return as("remove", { op: { kind: "items" } });
|
|
759
|
+
case "rewrite-item":
|
|
760
|
+
return op.kind === "case" ? as("change-case", { op: { mode: op.mode } }) : as("trim", { op: { bullets: true } });
|
|
761
|
+
case "keep-first": return as("keep-first", { op: { n: op.n, unit: "items" } });
|
|
762
|
+
default: return f;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
612
766
|
// ---- the modifiers -------------------------------------------------------------
|
|
613
767
|
|
|
614
768
|
/** What an item's numbering looks like: "[1] suv", "(2) car", "3. dirt",
|
|
@@ -651,17 +805,17 @@ export function applyCase(item , mode ) {
|
|
|
651
805
|
}
|
|
652
806
|
}
|
|
653
807
|
|
|
654
|
-
/** The id of the synthetic modifier
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
808
|
+
/** The id of the synthetic modifier the filters below a job's Response Format
|
|
809
|
+
apply under at run time (#339, #615): it is never in a document or the Add
|
|
810
|
+
menu -- a job carries a `filters` step, which `stageModifiers` turns into
|
|
811
|
+
these where the step sits, so a set cleans and gates a list reply's items
|
|
812
|
+
exactly as it reads. The filters above the format run on the raw reply
|
|
813
|
+
before it is read, not as a modifier. */
|
|
660
814
|
export const FILTER_MODIFIER = "filterSet";
|
|
661
815
|
|
|
662
816
|
const LIST_MODIFIERS = {
|
|
663
|
-
//
|
|
664
|
-
// run's only reader of
|
|
817
|
+
// The filters below the format, applied to the items where the step sits:
|
|
818
|
+
// the run's only reader of them, hidden from the Add menu
|
|
665
819
|
// (a set is linked in Responses, not added as a modifier). The lab's own
|
|
666
820
|
// cleaning modifiers are retired (#405) -- every cleaning step is a filter.
|
|
667
821
|
[FILTER_MODIFIER]: {
|
|
@@ -715,31 +869,32 @@ export const CLEANING_MODIFIERS = ["reject", "drop", "tidy", "drop-echoes", "ded
|
|
|
715
869
|
const baseFilter = (id , type , extra = {}) =>
|
|
716
870
|
({ id, type, match: {}, options: { ignoreFailures: false, repeat: 1 }, tests: [], ...extra });
|
|
717
871
|
|
|
718
|
-
/** The
|
|
719
|
-
same order and reading a reply the same way: Tidy
|
|
720
|
-
|
|
721
|
-
and `duplicate` conditions, Keep first
|
|
722
|
-
each of their rules a
|
|
723
|
-
drop reasons a run shows stay the names they read
|
|
872
|
+
/** The filter(s) below the format a retired cleaning modifier [m] becomes,
|
|
873
|
+
in the same order and reading a reply the same way: Tidy is a Trim with
|
|
874
|
+
bullets, Case a Change case, Drop prompt echoes and Remove duplicates a
|
|
875
|
+
Remove on the `echoes` and `duplicate` conditions, Keep first a Keep first
|
|
876
|
+
of items, and Drop items / Reject each of their rules a Remove / Reject
|
|
877
|
+
(filterFromItemRule). The drop reasons a run shows stay the names they read
|
|
878
|
+
as before. */
|
|
724
879
|
export function cleaningFilters(m ) {
|
|
725
880
|
switch (m.type) {
|
|
726
|
-
case "reject": return ((m.rules ) || []).map(r => filterFromItemRule(r, "reject
|
|
727
|
-
case "drop": return ((m.rules ) || []).map(r => filterFromItemRule(r, "
|
|
728
|
-
case "tidy": return [baseFilter("tidy", "
|
|
729
|
-
case "drop-echoes": return [baseFilter(ECHO, "
|
|
730
|
-
case "dedupe": return [baseFilter(DUPLICATE, "
|
|
731
|
-
case "keep-first": return [baseFilter("keep first", "keep-first", { op: { n: m.n
|
|
732
|
-
case "case": return [baseFilter("case", "
|
|
881
|
+
case "reject": return ((m.rules ) || []).map(r => filterFromItemRule(r, "reject"));
|
|
882
|
+
case "drop": return ((m.rules ) || []).map(r => filterFromItemRule(r, "remove"));
|
|
883
|
+
case "tidy": return [baseFilter("tidy", "trim", { op: { bullets: true } })];
|
|
884
|
+
case "drop-echoes": return [baseFilter(ECHO, "remove", { match: { echoes: true }, op: { kind: "items" } })];
|
|
885
|
+
case "dedupe": return [baseFilter(DUPLICATE, "remove", { match: { duplicate: true }, op: { kind: "items" } })];
|
|
886
|
+
case "keep-first": return [baseFilter("keep first", "keep-first", { op: { n: m.n , unit: "items" } })];
|
|
887
|
+
case "case": return [baseFilter("case", "change-case", { op: { mode: (m.mode ) ?? "LOWER" } })];
|
|
733
888
|
default: return [];
|
|
734
889
|
}
|
|
735
890
|
}
|
|
736
891
|
|
|
737
|
-
/** The
|
|
738
|
-
|
|
892
|
+
/** The filters above the format the List `preamble` setting becomes: Remove
|
|
893
|
+
thinking then Remove preamble, which together read a reply exactly as
|
|
739
894
|
`preamble: true` did (#405). */
|
|
740
895
|
export function preambleFilters() {
|
|
741
|
-
return [baseFilter("remove thinking", "
|
|
742
|
-
baseFilter("remove preamble", "
|
|
896
|
+
return [baseFilter("remove thinking", "remove", { op: { kind: "thinking" } }),
|
|
897
|
+
baseFilter("remove preamble", "remove", { op: { kind: "preamble" } })];
|
|
743
898
|
}
|
|
744
899
|
|
|
745
900
|
/**
|