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.
Files changed (50) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2 -2
  3. package/bin/run.js +4 -2
  4. package/lab/VERSION +1 -1
  5. package/lab/demo/CREDITS.md +14 -0
  6. package/lab/demo/datasets/demo-1.json +2 -2
  7. package/lab/demo/datasets/demo-2.json +2 -2
  8. package/lab/demo/datasets/documents-type.json +79 -0
  9. package/lab/demo/datasets/documents-valid.json +117 -0
  10. package/lab/demo/datasets/sentiment.json +2 -2
  11. package/lab/demo/datasets/weather-alerts.json +2 -2
  12. package/lab/demo/datasets/wordings.json +1 -1
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +17 -14
  15. package/lab/demo/pipelines/demo-2.json +17 -14
  16. package/lab/demo/pipelines/documents.json +101 -0
  17. package/lab/demo/pipelines/sentiment.json +4 -4
  18. package/lab/demo/pipelines/weather-alerts.json +9 -5
  19. package/lab/demo/pipelines/wordings.json +3 -3
  20. package/lab/demo/sources/Demo 1/source.json +1 -1
  21. package/lab/demo/sources/Demo 2/source.json +1 -1
  22. package/lab/demo/sources/documents/invoice.pdf +65 -0
  23. package/lab/demo/sources/documents/letter.pdf +63 -0
  24. package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
  25. package/lab/demo/sources/documents/receipt.pdf +62 -0
  26. package/lab/demo/sources/documents/report.pdf +59 -0
  27. package/lab/demo/sources/documents/source.json +3 -0
  28. package/lab/demo/sources/documents/truncated.pdf +19 -0
  29. package/lab/demo/sources/sentiment/source.json +1 -1
  30. package/lab/demo/sources/weather-alerts/source.json +1 -1
  31. package/lab/evals-core.mjs +408 -160
  32. package/lab/flows/record.mjs +5 -4
  33. package/lab/kinds/list.mjs +337 -182
  34. package/lab/run-evals.js +3 -1
  35. package/lab/server.py +661 -108
  36. package/lab/web/dist/assets/gallery-Cy8oycht.js +5 -0
  37. package/lab/web/dist/assets/gallery-eVJoaK-6.css +1 -0
  38. package/lab/web/dist/assets/main-B-LGmhPu.css +1 -0
  39. package/lab/web/dist/assets/main-wqNvXpWp.js +15 -0
  40. package/lab/web/dist/assets/tokens-CWVzmtid.css +1 -0
  41. package/lab/web/dist/assets/tokens-mCS6IZGh.js +64 -0
  42. package/lab/web/dist/gallery.html +4 -4
  43. package/lab/web/dist/index.html +4 -4
  44. package/package.json +1 -1
  45. package/lab/web/dist/assets/gallery-B_-TH0F-.css +0 -1
  46. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +0 -5
  47. package/lab/web/dist/assets/main-BSw-jyYD.css +0 -1
  48. package/lab/web/dist/assets/main-CrB8wPZn.js +0 -17
  49. package/lab/web/dist/assets/tokens-CrjDMCbz.js +0 -60
  50. package/lab/web/dist/assets/tokens-DDOONQon.css +0 -1
@@ -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 Drop item (and a Rewrite item's limit) may hold: the
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 carries today. Where a
229
- // rule was a bare matcher with examples, a filter has a base TYPE from a
230
- // registry (not an enum) -- "drop-item" cleans each item of a list reply,
231
- // "reject-reply" gates the whole answer -- generic conditions reusing the
232
- // matcher above (ANDed), Options, and Tests that are its own checks. The two
233
- // base types evaluate here, beside the matcher and rejectHolds they are built
234
- // from; the Library-stored filter-set body, the pipeline link that carries
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, for a reject-reply filter that reads the text. */
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`. A drop-item filter's expected output is the
249
- items it keeps; a reject-reply filter's is blank when the reply should be
250
- rejected, or left non-blank when it should pass. */
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, #405): a base TYPE fixed at creation (a FILTER_TYPES
265
- id), generic conditions in `match` (the matcher's keys for its type, ANDed),
266
- an optional `op` carrying the type's own operation (Rewrite's and Keep
267
- first's -- Drop item and the rejects hold none), Options and Tests. `id` is
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 works over: the text the reply came as (which a text-scope
284
- filter rewrites, and the items are parsed from), the items parsed from it,
285
- and the stage's instruction a filter may read (an item that echoes it). */
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 Rewrite text filter
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). A reader
301
- asks the entry by `filter.type`; nothing branches on the id. */
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
- /** The whole-answer conditions a Reject text filter reads over the raw text:
328
- `blank`, and `regex` searched anywhere in it (unanchored, case-insensitive
329
- -- a text reject looks for a phrase, not a whole-string match). */
330
- function rejectTextHolds(m , text ) {
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(!text.trim());
333
- if (typeof m.regex === "string") { const r = m.regex; let re ; try { re = new RegExp(r, "i"); } catch { re = null; } conds.push(!!re && re.test(text)); }
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
- /** The function a Rewrite item filter's `op` names: Tidy, or Case in its
338
- mode. An unknown op leaves the item as it is. */
339
- function rewriteItem(op ) {
340
- if (op?.kind === "tidy") return tidy;
341
- if (op?.kind === "case") { const mode = op.mode ; return t => applyCase(t, mode); }
342
- return t => t;
343
- }
344
-
345
- /** The function a Rewrite text filter's `op` names: Remove thinking, Remove
346
- preamble, or Replace pattern (a regex, global, and its replacement). An
347
- unknown op leaves the text as it is; a pattern that will not compile throws,
348
- so Ignore failures can carry a run past it. */
349
- function rewriteText(op ) {
350
- if (op?.kind === "thinking") return removeThinking;
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
- return text => text;
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
- /** The lab's base filter types (#405). Generic code asks this registry by
361
- `filter.type`; it never names an id. `rewrite-text`/`reject-text` read the
362
- raw reply before it is read into items (the Pre-format stage); the rest
363
- read the items after (the Post-format stage). */
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
- "rewrite-text": {
366
- label: "Rewrite text", about: "Remove thinking, a preamble or a pattern from the reply's text.", scope: "text", conditions: [],
367
- opProblems: (f, at) => opKindProblems(f.op, at, {
368
- thinking: [], preamble: [], replace: ["pattern", "replacement"],
369
- }),
370
- apply: (filter, reply) => ({ body: rewriteText(filter.op)(reply.body), items: reply.items, dropped: [], reject: null }),
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-text": {
373
- label: "Reject text", about: "Discard the reply when it is blank or matches a pattern.", scope: "text", conditions: ["blank", "regex"], rejects: true,
374
- apply: (filter, reply) => ({ body: reply.body, items: reply.items, dropped: [], reject: rejectTextHolds(filter.match, reply.body) ? filter.id : null }),
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
- "drop-item": {
377
- label: "Drop item", about: "Remove the items that match its conditions.", scope: "item", conditions: ITEM_CONDITIONS,
378
- apply(filter, reply) {
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
- "rewrite-item": {
392
- label: "Rewrite item", about: "Tidy each item, or change its case.", scope: "item", conditions: MATCHER_KEYS,
393
- opProblems: (f, at) => opKindProblems(f.op, at, { tidy: [], case: ["mode"] }, op =>
394
- op.kind === "case" && !CASE_MODES.includes(op.mode ) ? [`${at}'s case needs a mode`] : []),
395
- apply(filter, reply) {
396
- const test = matcher(filter.match);
397
- const rewrite = rewriteItem(filter.op);
398
- // Only matched items are rewritten; a blank result is dropped, as Tidy
399
- // has always dropped an item it emptied.
400
- const items = reply.items.map(t => (!test || test(t) ? rewrite(t) : t)).filter(Boolean);
401
- return { body: reply.body, items, dropped: [], reject: null };
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
- "keep-first": {
405
- label: "Keep first", about: "Keep the first N items.", scope: "list", conditions: [],
406
- opProblems: (f, at) => (Number.isInteger(f.op?.n) && (f.op .n ) >= 1
407
- ? extraOpKeys(f.op, ["n"], at) : [`${at} needs a whole number of 1 or more`]),
408
- apply(filter, reply) {
409
- const n = filter.op?.n ;
410
- return { body: reply.body, items: reply.items.slice(0, n), dropped: reply.items.slice(n).map(t => ({ item: t, by: pastN(n) })), reject: null };
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
- "reject-reply": {
414
- label: "Reject reply", about: "Discard the whole reply when its items match its conditions.", scope: "answer", conditions: ["blank", "itemCount", "share", "items"], rejects: true,
415
- apply: (filter, reply) => ({ body: reply.body, items: reply.items, dropped: [], reject: rejectHolds(filter.match, reply.body, reply.items) ? filter.id : null }),
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], honouring its Options: `repeat` passes over
438
- the reply (a filter that rejects stops at the first that fires), and
439
- `ignoreFailures` leaves the reply as it was if the filter errors -- a
440
- condition or pattern that cannot be read -- rather than failing the run.
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 a filter set's filters to [reply], in order: a Rewrite text filter
468
- reshapes the text the next reads, each item filter narrows or rewrites the
469
- items, and the first filter that rejects gates the answer and stops the
470
- rest. Given a [stage], only the filters whose type runs there apply: a set
471
- belongs to no stage (#432), so one linked both Pre-format and Post-format runs
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 scope = FILTER_TYPES[filter.type]?.scope;
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: the sample read
508
- the way the filter's scope reads it, the filter applied, and the result
509
- compared with the expected output. A rejecting filter passes when the reply
510
- is rejected exactly if the expected output is blank; a Rewrite text filter
511
- when the text it leaves reads as the expected text; an item or list filter
512
- when the items it keeps read as the expected items. */
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
- // A text filter reads the whole sample; an item or list filter splits it.
518
- const items = scope === "text" ? [] : splitSample(test.sampleInput, test.separator);
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" : (scope === "text" ? out.body : out.items.join(", ")) };
627
+ return { test, ok: rejected === !test.expectedOutput.trim(), got: rejected ? "rejected" : (on === "text" ? out.body : out.items.join(", ")) };
523
628
  }
524
- if (scope === "text") {
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's scope takes, and an
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) bad.push(...entry.opProblems(f, `${at}: ${id}`));
581
- else if (f.op != null) bad.push(`${at}: ${id} takes no operation`);
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 base [type] the one the modifier carrying it
588
- fixed ("drop-item" for Drop items, "reject-reply" for Reject the answer),
589
- its Options the defaults, and its examples its tests -- a caught example
590
- becomes a sample the filter should empty (drop-item) or reject
591
- (reject-reply), a kept example one it should leave. Pure: the match is
592
- carried unchanged, so the filter cleans a reply exactly as the rule did.
593
- The only reader of the old shape, for Runs to migrate its inline rules
594
- (#339). */
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 = scope === "item" ? ex.drops || [] : ex.rejects || [];
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 a linked or private Post-format filter
655
- set applies under at run time (#339, #405): it is never in a document or
656
- the Add menu -- a job carries a `filters` step, which `stageModifiers`
657
- turns into one of these where the step sits, so a set cleans and gates a
658
- list reply exactly as it reads. The Pre-format (text) stage runs its
659
- filters on the raw reply before it is read, not as a modifier. */
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
- // A Post-format filter set's filters, applied where its link sits: the
664
- // run's only reader of a resolved items-stage set, hidden from the Add menu
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 Post-format filter(s) a retired cleaning modifier [m] becomes, in the
719
- same order and reading a reply the same way: Tidy and Case are a Rewrite
720
- item, Drop prompt echoes and Remove duplicates a Drop item on the `echoes`
721
- and `duplicate` conditions, Keep first its own type, and Drop items / Reject
722
- each of their rules a Drop item / Reject reply (filterFromItemRule). The
723
- drop reasons a run shows stay the names they read as before. */
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-reply"));
727
- case "drop": return ((m.rules ) || []).map(r => filterFromItemRule(r, "drop-item"));
728
- case "tidy": return [baseFilter("tidy", "rewrite-item", { op: { kind: "tidy" } })];
729
- case "drop-echoes": return [baseFilter(ECHO, "drop-item", { match: { echoes: true } })];
730
- case "dedupe": return [baseFilter(DUPLICATE, "drop-item", { match: { duplicate: true } })];
731
- case "keep-first": return [baseFilter("keep first", "keep-first", { op: { n: m.n } })];
732
- case "case": return [baseFilter("case", "rewrite-item", { op: { kind: "case", mode: (m.mode ) ?? "LOWER" } })];
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 Pre-format (text) filters the List `preamble` setting becomes:
738
- Remove thinking then Remove preamble, which together read a reply exactly as
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", "rewrite-text", { op: { kind: "thinking" } }),
742
- baseFilter("remove preamble", "rewrite-text", { op: { kind: "preamble" } })];
896
+ return [baseFilter("remove thinking", "remove", { op: { kind: "thinking" } }),
897
+ baseFilter("remove preamble", "remove", { op: { kind: "preamble" } })];
743
898
  }
744
899
 
745
900
  /**