evals-lab 0.12.0 → 0.14.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 +88 -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 +4 -4
  15. package/lab/demo/pipelines/demo-2.json +4 -4
  16. package/lab/demo/pipelines/documents.json +102 -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 +382 -84
  32. package/lab/flows/record.mjs +5 -4
  33. package/lab/flows/wdl.mjs +19 -0
  34. package/lab/kinds/list.mjs +77 -23
  35. package/lab/metrics/builtin.mjs +34 -7
  36. package/lab/run-evals.js +33 -12
  37. package/lab/server.py +668 -108
  38. package/lab/web/dist/assets/gallery-qrf0ZGXK.js +5 -0
  39. package/lab/web/dist/assets/main-D6xARg2A.js +16 -0
  40. package/lab/web/dist/assets/main-xVU6J7Gi.css +1 -0
  41. package/lab/web/dist/assets/tokens-C8dqXDHN.js +62 -0
  42. package/lab/web/dist/assets/tokens-tQ7zTPkd.css +1 -0
  43. package/lab/web/dist/gallery.html +3 -3
  44. package/lab/web/dist/index.html +4 -4
  45. package/package.json +1 -1
  46. package/lab/web/dist/assets/gallery-CFntjQFG.js +0 -5
  47. package/lab/web/dist/assets/main-BlTG2i20.css +0 -1
  48. package/lab/web/dist/assets/main-D6MNWqak.js +0 -17
  49. package/lab/web/dist/assets/tokens-DwzzdSIF.css +0 -1
  50. package/lab/web/dist/assets/tokens-bvuNFuYg.js +0 -60
@@ -153,11 +153,12 @@ export function skeletonFor(step , reads ) {
153
153
  call of the step at that iteration: imported twice, it is skipped the
154
154
  second time, while another run's call -- the same request, sent again,
155
155
  answered afresh -- is its own record, since a flow that sends the same
156
- words every time is exactly where production's replies differ. One
157
- written by hand or uploaded is the same as another that read and sent the
158
- same things. */
156
+ words every time is exactly where production's replies differ. An edited
157
+ call keeps its run's key, so Import runs skips it too (#587). One written
158
+ by hand or uploaded is the same as another that read and sent the same
159
+ things. Mirrored by server.py's record_key. */
159
160
  export function recordKey(r ) {
160
- if (r.origin?.kind === "run" && r.origin.run) {
161
+ if (r.origin?.run) {
161
162
  return JSON.stringify({ step: r.step, run: r.origin.run, iteration: r.origin.iteration ?? "" });
162
163
  }
163
164
  const sorted = (v ) => Array.isArray(v) ? v.map(sorted)
package/lab/flows/wdl.mjs CHANGED
@@ -363,6 +363,25 @@ function endOf(s , from ) {
363
363
  throw new WdlError("an @{ is never closed");
364
364
  }
365
365
 
366
+ /** Why [template] cannot be evaluated whatever it is evaluated against -- an
367
+ expression in it that does not parse -- or null. Read before a run, so a
368
+ template every call would fail on is refused rather than sent. */
369
+ export function templateProblem(template ) {
370
+ try { walk(template); return null; }
371
+ catch (e) { if (e instanceof WdlError) return e.message; throw e; }
372
+ function walk(v ) {
373
+ if (Array.isArray(v)) return v.forEach(walk);
374
+ if (isObj(v)) return Object.values(v).forEach(walk);
375
+ if (typeof v !== "string" || v.startsWith("@@")) return;
376
+ if (v.startsWith("@") && !v.startsWith("@{")) return void parse(v.slice(1));
377
+ for (let open = v.indexOf("@{"); open >= 0; open = v.indexOf("@{", open)) {
378
+ const close = endOf(v, open + 2);
379
+ parse(v.slice(open + 2, close));
380
+ open = close + 1;
381
+ }
382
+ }
383
+ }
384
+
366
385
  /** [template] with every expression in it evaluated: each string that is
367
386
  one, or holds one, wherever it sits in objects and arrays. */
368
387
  export function evaluate(template , ctx ) {
@@ -293,6 +293,8 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
293
293
 
294
294
 
295
295
 
296
+
297
+
296
298
 
297
299
 
298
300
  /** A base filter type (#334's decision: a registry, not an enum). A reader
@@ -302,10 +304,10 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
302
304
 
303
305
 
304
306
 
305
-
306
-
307
-
308
-
307
+
308
+
309
+
310
+
309
311
 
310
312
 
311
313
 
@@ -340,32 +342,81 @@ function rewriteItem(op )
340
342
  return t => t;
341
343
  }
342
344
 
343
- /** The function a Rewrite text filter's `op` names: Remove thinking, Remove
344
- preamble, or Replace pattern (a regex, global, and its replacement). An
345
- unknown op leaves the text as it is; a pattern that will not compile throws,
346
- so Ignore failures can carry a run past it. */
347
- function rewriteText(op ) {
348
- if (op?.kind === "thinking") return removeThinking;
349
- if (op?.kind === "preamble") return removePreamble;
350
- if (op?.kind === "replace") {
351
- const re = new RegExp(String(op.pattern ?? ""), "g");
352
- const to = typeof op.replacement === "string" ? op.replacement : "";
353
- return text => text.replace(re, to);
345
+ /** The function a Rewrite text filter's `op` names (#557): Remove thinking,
346
+ Remove preamble, Trim, Replace text (literal, every occurrence), Replace
347
+ pattern (a regex, global, and its replacement), Strip code fences, Change
348
+ case, Keep first (N lines or characters), or Extract (a pattern's first
349
+ match, its first group if it has one). It answers null where nothing is
350
+ left to keep -- Extract found no match -- which rejects the reply. An
351
+ unknown op leaves the text as it is; a pattern that will not compile
352
+ throws, so Ignore failures can carry a run past it. */
353
+ function rewriteText(op ) {
354
+ switch (op?.kind) {
355
+ case "thinking": return removeThinking;
356
+ case "preamble": return removePreamble;
357
+ case "trim": return text => text.trim();
358
+ case "literal": {
359
+ const find = String(op.find ?? "");
360
+ const to = typeof op.replacement === "string" ? op.replacement : "";
361
+ return text => (find ? text.split(find).join(to) : text);
362
+ }
363
+ case "replace": {
364
+ const re = new RegExp(String(op.pattern ?? ""), "g");
365
+ const to = typeof op.replacement === "string" ? op.replacement : "";
366
+ return text => text.replace(re, to);
367
+ }
368
+ // A fence's own lines go -- the opening one with its language, and the
369
+ // closing one -- wherever they sit, so a reply fenced whole or a fence
370
+ // after a line of preamble both read as what was inside.
371
+ case "fences": return text => text.replace(FENCE_LINE, "");
372
+ case "case": { const mode = op.mode ; return text => applyCase(text, mode); }
373
+ case "first": {
374
+ const n = op.n ;
375
+ return op.unit === "chars" ? text => text.slice(0, n) : text => text.split("\n").slice(0, n).join("\n");
376
+ }
377
+ case "extract": {
378
+ const re = new RegExp(String(op.pattern ?? ""));
379
+ return text => { const m = re.exec(text); return m ? (m.length > 1 ? m[1] ?? "" : m[0]) : null; };
380
+ }
381
+ default: return text => text;
354
382
  }
355
- return text => text;
356
383
  }
357
384
 
385
+ const FENCE_LINE = /^[ \t]*```[^`\n]*(?:\n|$)/gm;
386
+
387
+ /** Keep first's units, for a Rewrite text: lines, or characters. */
388
+ const TEXT_UNITS = ["lines", "chars"] ;
389
+
390
+ /** Why a Rewrite text op's pattern does not compile, as a sentence, or none. */
391
+ const patternProblems = (pattern , at ) => {
392
+ if (typeof pattern !== "string" || !pattern) return [`${at} needs a pattern`];
393
+ try { new RegExp(pattern); return []; } catch { return [`${at}'s pattern does not compile`]; }
394
+ };
395
+
358
396
  /** The lab's base filter types (#405). Generic code asks this registry by
359
397
  `filter.type`; it never names an id. `rewrite-text`/`reject-text` read the
360
- raw reply before it is read into items (the Pre-format stage); the rest
361
- read the items after (the Post-format stage). */
398
+ reply's text: Pre-format, or Post-format after a kind that reads no items
399
+ (#557); the rest read the items, Post-format after a kind that does. */
362
400
  export const FILTER_TYPES = {
363
401
  "rewrite-text": {
364
- label: "Rewrite text", about: "Remove thinking, a preamble or a pattern from the reply's text.", scope: "text", conditions: [],
402
+ label: "Rewrite text", about: "Trim, replace, unfence, recase, shorten or extract from the reply's text.", scope: "text", conditions: [],
365
403
  opProblems: (f, at) => opKindProblems(f.op, at, {
366
- thinking: [], preamble: [], replace: ["pattern", "replacement"],
404
+ thinking: [], preamble: [], trim: [], literal: ["find", "replacement"], replace: ["pattern", "replacement"],
405
+ fences: [], case: ["mode"], first: ["n", "unit"], extract: ["pattern"],
406
+ }, op => {
407
+ if (op.kind === "literal" && (typeof op.find !== "string" || !op.find)) return [`${at} needs text to find`];
408
+ if (op.kind === "extract") return patternProblems(op.pattern, at);
409
+ if (op.kind === "case" && !CASE_MODES.includes(op.mode )) return [`${at}'s case needs a mode`];
410
+ if (op.kind === "first") {
411
+ return [...(Number.isInteger(op.n) && (op.n ) >= 1 ? [] : [`${at} needs a whole number of 1 or more`]),
412
+ ...(TEXT_UNITS.includes(op.unit ) ? [] : [`${at} keeps lines or characters`])];
413
+ }
414
+ return [];
367
415
  }),
368
- apply: (filter, reply) => ({ body: rewriteText(filter.op)(reply.body), items: reply.items, dropped: [], reject: null }),
416
+ apply(filter, reply) {
417
+ const body = rewriteText(filter.op)(reply.body);
418
+ return { body: body ?? reply.body, items: reply.items, dropped: [], reject: body == null ? filter.id : null };
419
+ },
369
420
  },
370
421
  "reject-text": {
371
422
  label: "Reject text", about: "Discard the reply when it is blank or matches a pattern.", scope: "text", conditions: ["blank", "regex"], rejects: true,
@@ -471,19 +522,20 @@ export function applyFilter(filter , reply ) {
471
522
  export function applyFilterSet(filters , reply , stage ) {
472
523
  const instruction = reply.instruction ?? "";
473
524
  let body = reply.body, items = reply.items;
474
- const dropped = [];
525
+ const dropped = [], rewrote = [];
475
526
  let emptied ;
476
527
  for (const filter of filters) {
477
528
  const scope = FILTER_TYPES[filter.type]?.scope;
478
529
  if (stage && scope && (scope === "text") !== (stage === "text")) continue;
479
530
  const out = applyFilter(filter, { body, items, instruction });
531
+ if (out.body !== body) rewrote.push(filter.id);
480
532
  body = out.body;
481
533
  if (items.length && !out.items.length) emptied = filter.id;
482
534
  items = out.items;
483
535
  dropped.push(...out.dropped);
484
536
  if (out.reject != null) return { body, items, dropped, reject: out.reject };
485
537
  }
486
- return { body, items, dropped, reject: null, ...(emptied ? { emptied } : {}) };
538
+ return { body, items, dropped, reject: null, ...(emptied ? { emptied } : {}), ...(rewrote.length ? { rewrote } : {}) };
487
539
  }
488
540
 
489
541
  // A newline always ends an item, as it does in the real csv/lines parse
@@ -519,6 +571,8 @@ export function tryFilter(filter )
519
571
  return { test, ok: rejected === !test.expectedOutput.trim(), got: rejected ? "rejected" : (scope === "text" ? out.body : out.items.join(", ")) };
520
572
  }
521
573
  if (scope === "text") {
574
+ // A Rewrite text that rejects (Extract, matching nothing) leaves nothing.
575
+ if (out.reject != null) return { test, ok: !test.expectedOutput.trim(), got: "rejected" };
522
576
  const got = out.body.trim();
523
577
  return { test, ok: got === test.expectedOutput.trim(), got };
524
578
  }
@@ -139,9 +139,9 @@ function verdictOf(said )
139
139
  can hold one. A reply that still cannot be read is asked for once more;
140
140
  a second, or a grader that failed, is the grader's error -- said as one,
141
141
  so it is not read as the reply failing. [ok] says whether a verdict read
142
- is one this metric can use. */
142
+ is one this metric can use: true, or why not. */
143
143
  async function graded(ctx , prompt , schema ,
144
- ok = () => true) {
144
+ ok = () => true) {
145
145
  if (!ctx.ask) throw new Error("a model-graded metric needs a grader, and this run has none");
146
146
  let last = "";
147
147
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -149,14 +149,21 @@ async function graded(ctx , prompt , schema
149
149
  try { said = await ctx.ask(prompt, schema); } catch (e) { throw new Error(`grader error: ${e instanceof Error ? e.message : String(e)}`); }
150
150
  try {
151
151
  const v = verdictOf(said);
152
- if (ok(v)) return v;
153
- last = `the grader said ${short(JSON.stringify(v))}`;
152
+ const usable = ok(v);
153
+ if (usable === true) return v;
154
+ last = typeof usable === "string" ? usable : `the grader said ${short(JSON.stringify(v))}`;
154
155
  } catch (e) { last = e instanceof Error ? e.message : String(e); }
155
156
  }
156
157
  throw new Error(`grader error: ${last}`);
157
158
  }
159
+ /** Whether a pass-or-fail verdict's score is on the scale it was asked for.
160
+ One off it -- a 4 echoing a 1-to-5 rating -- is not clamped to a score the
161
+ grader never gave: it is read as unreadable, asked for again, then the
162
+ grader's error (#514). */
163
+ const onScale = (v ) =>
164
+ typeof v.score !== "number" || (v.score >= 0 && v.score <= 1) || `the grader scored ${v.score}, off the 0 to 1 scale`;
158
165
  const gradedPass = (v , threshold ) => {
159
- const score = typeof v.score === "number" ? Math.max(0, Math.min(1, v.score)) : v.pass === true ? 1 : 0;
166
+ const score = typeof v.score === "number" ? v.score : v.pass === true ? 1 : 0;
160
167
  const pass = typeof v.pass === "boolean" ? v.pass && score >= threshold : score >= threshold;
161
168
  return { pass, score, reason: str(v.reason) || (pass ? "the grader passed it" : "the grader failed it") };
162
169
  };
@@ -339,6 +346,7 @@ const metrics = {
339
346
  // rules discarded is the finding.
340
347
  discarded: {
341
348
  family: RESULT,
349
+ readsResult: true,
342
350
  label: "Discarded",
343
351
  description: "Passes only when the job's rules discarded the reply.",
344
352
  options: [],
@@ -348,8 +356,26 @@ const metrics = {
348
356
  return ok(thrown, thrown ? "discarded" : input.error ? input.error : `kept ${input.terms.length} items`);
349
357
  },
350
358
  },
359
+ // A case that expects no answer at all: the connection answering an error
360
+ // -- a 400 for a file it can't read -- is the finding. A discard is
361
+ // Discarded's, at whichever stage it was.
362
+ refused: {
363
+ family: RESULT,
364
+ readsResult: true,
365
+ readsRefusal: true,
366
+ label: "Refused",
367
+ description: "Passes only when the target's call was refused: an error from the connection, not a reply.",
368
+ options: [],
369
+ defaults: () => ({}),
370
+ score: (input) => {
371
+ if (!input.error) return ok(false, "replied");
372
+ const refused = !/^(stage \d+: )?discarded: /.test(input.error);
373
+ return ok(refused, refused ? `refused: ${input.error}` : input.error);
374
+ },
375
+ },
351
376
  "item-count": {
352
377
  family: RESULT,
378
+ readsResult: true,
353
379
  label: "Item count",
354
380
  description: "Passes when the number of items is within the bounds.",
355
381
  options: [{ key: "min", label: "At least", type: "number" }, { key: "max", label: "At most", type: "number" }],
@@ -385,6 +411,7 @@ const metrics = {
385
411
  },
386
412
  latency: {
387
413
  family: RESULT,
414
+ readsResult: true,
388
415
  label: "Latency",
389
416
  description: "Passes when the reply came back within the time set.",
390
417
  options: [{ key: "max", label: "At most (ms)", type: "number" }],
@@ -472,7 +499,7 @@ const metrics = {
472
499
  validate: (m, at, bad) => needs(m, ["rubric"], at, bad),
473
500
  score: async (input, m, ctx) => gradedPass(await graded(ctx,
474
501
  `You are grading an output against a rubric.\n\n${taskOf(input, "")}Rubric:\n${str(m.rubric)}\n\nOutput:\n${input.text}\n\n${REPLY}`,
475
- VERDICT_SCHEMA), num(m.threshold) ?? 0.5),
502
+ VERDICT_SCHEMA, onScale), num(m.threshold) ?? 0.5),
476
503
  },
477
504
  factuality: {
478
505
  family: GRADED,
@@ -488,7 +515,7 @@ const metrics = {
488
515
  score: async (input, m, ctx) => gradedPass(await graded(ctx,
489
516
  `You are checking an output for factual consistency with a reference. Differences in wording or detail are `
490
517
  + `fine; a contradiction, or a fact the reference does not support, is not.\n\n${taskOf(input, "")}Reference:\n`
491
- + `${str(m.reference).trim() || recorded(input)}\n\nOutput:\n${input.text}\n\n${REPLY}`, VERDICT_SCHEMA), num(m.threshold) ?? 0.5),
518
+ + `${str(m.reference).trim() || recorded(input)}\n\nOutput:\n${input.text}\n\n${REPLY}`, VERDICT_SCHEMA, onScale), num(m.threshold) ?? 0.5),
492
519
  },
493
520
  "judge-vs-production": {
494
521
  family: GRADED,
package/lab/run-evals.js CHANGED
@@ -479,20 +479,24 @@ function prepare(bin, file, c) {
479
479
 
480
480
  // ---- The type → handler registry ------------------------------------------
481
481
  // Design §4. What a file *is* to a run is decided here and only here, so the
482
- // queue and the page never learn about file types, and pdf and xlsx are
483
- // later entries that touch nothing but this map.
482
+ // queue and the page never learn about file types, and xlsx is a later
483
+ // entry that touches nothing but this map.
484
484
  //
485
485
  // A file with no mapper is passed through anyway -- decoded as UTF-8 with
486
486
  // replacement and joined at {text} like the text handler, and flagged in the
487
- // transcript. Stating the failure mode beats hiding it: a PDF's text layer
488
- // is not extracted, so its streams arrive as arbitrary bytes, and a run
489
- // that read them "well" would be a finding about nothing.
487
+ // transcript. Stating the failure mode beats hiding it: a binary format's
488
+ // bytes arrive as mojibake, and a run that read them "well" would be a
489
+ // finding about nothing.
490
490
  const HANDLERS = new Map();
491
491
  [
492
492
  // Images: prepared to the profile's pixel budget by ImageMagick and sent
493
493
  // as the image content part. The formats are server.py's FILE_TYPES
494
494
  // image half, so nothing accepted for upload has no executor to run it.
495
495
  ["image", "jpg", "jpeg", "png", "heic", "heif", "dng", "tif", "tiff"],
496
+ // Documents: the file's bytes, unprocessed, handed to an API that reads
497
+ // them whole (core.DOCUMENT_TYPES); a connection that cannot is refused
498
+ // before the run, by validatePipeline.
499
+ ["document", ...Object.keys(core.DOCUMENT_TYPES).map(e => e.slice(1))],
496
500
  // Text-ish: decoded UTF-8, joining the stage at {text} -- the text-content
497
501
  // semantics the canvas already has (core.textPrompt).
498
502
  ["text", "txt", "md", "csv"],
@@ -511,8 +515,8 @@ const UNMAPPED = "the file has no handler, so it was passed through as raw "
511
515
 
512
516
  /**
513
517
  * One file of the Source made into what the job consumes: an image as the
514
- * dataUrl at [conn]'s budget, or text at {text}. A file with no mapper
515
- * still runs, and says so.
518
+ * dataUrl at [conn]'s budget, a document as its bytes, or text at {text}. A
519
+ * file with no mapper still runs, and says so.
516
520
  */
517
521
  function itemContent(bin, file, conn) {
518
522
  const kind = HANDLERS.get(extOf(file));
@@ -520,6 +524,7 @@ function itemContent(bin, file, conn) {
520
524
  const { dataUrl, dims } = prepare(bin, file, conn);
521
525
  return { kind, dataUrl, text: null, flags: [] };
522
526
  }
527
+ if (kind === "document") return { kind, dataUrl: documentOf(file), text: null, flags: [] };
523
528
  // TextDecoder with fatal unset replaces malformed bytes with U+FFFD --
524
529
  // what "decoded as UTF-8 with replacement" means, for a mapped-text file
525
530
  // and an unmapped one alike.
@@ -527,6 +532,13 @@ function itemContent(bin, file, conn) {
527
532
  return { kind: kind ?? "unmapped", dataUrl: null, text, flags: kind ? [] : [UNMAPPED] };
528
533
  }
529
534
 
535
+ /** A document file as the attachment a stage sends: its bytes, its media
536
+ type and its name. */
537
+ function documentOf(file) {
538
+ const name = path.basename(file);
539
+ return core.documentUrl(name, core.documentTypeOf(name).media, fs.readFileSync(file).toString("base64"));
540
+ }
541
+
530
542
  // ---- Reaching a model ----------------------------------------------------
531
543
 
532
544
  /**
@@ -712,7 +724,9 @@ async function ask(to, c, prompt, dataUrl, schema = null) {
712
724
  if (!r.ok) return said({ error: j.error?.message || j.error || `HTTP ${r.status}` });
713
725
  const { raw, finishReason } = core.connectionReply(c, j);
714
726
  const error = core.connectionReplyProblem(c, raw, finishReason);
715
- return said(error ? { raw, error } : { raw });
727
+ // Why it stopped, and the limit sent where the limit stopped it (#597).
728
+ const finish = core.replyFinish(c, req.body, finishReason);
729
+ return said(error ? { raw, error, ...finish } : { raw, ...finish });
716
730
  } catch (e) {
717
731
  if (e?.name === "TimeoutError" || e?.name === "AbortError") {
718
732
  return said({ error: `the model gave no reply within ${seconds}s — timed out` });
@@ -737,9 +751,10 @@ async function ask(to, c, prompt, dataUrl, schema = null) {
737
751
  // this invocation did, so resume and re-run item agree with a fresh run.
738
752
 
739
753
  // What a file *is* to a run, the same registry §4 gives the graded set: an
740
- // image is prepared to the profile's pixel budget; a text file is decoded
741
- // UTF-8 and joined at {text}; anything else is passed through as UTF-8 with
742
- // replacement and flagged, so the failure mode is stated rather than hidden.
754
+ // image is prepared to the profile's pixel budget; a document is sent whole;
755
+ // a text file is decoded UTF-8 and joined at {text}; anything else is passed
756
+ // through as UTF-8 with replacement and flagged, so the failure mode is
757
+ // stated rather than hidden.
743
758
  const RUN_IMAGE_TYPES = new Set([".jpg", ".jpeg", ".png", ".heic", ".heif", ".dng", ".tif", ".tiff"]);
744
759
  const RUN_TEXT_TYPES = new Set([".txt", ".md", ".csv"]);
745
760
 
@@ -916,6 +931,7 @@ async function runSnapshot(o) {
916
931
  : files.map(name => {
917
932
  const ext = path.extname(name).toLowerCase();
918
933
  if (RUN_IMAGE_TYPES.has(ext)) return { name, kind: "image" };
934
+ if (core.documentTypeOf(name)) return { name, kind: "document" };
919
935
  // A record (flows/record.ts): one call of a Power Automate step, what
920
936
  // an HTTP Request is rebuilt from.
921
937
  if (ext === ".json") return { name, kind: "record" };
@@ -981,6 +997,11 @@ async function runSnapshot(o) {
981
997
  break;
982
998
  }
983
999
  }
1000
+ if (item.kind === "document") {
1001
+ const file = path.join(o.source, item.name);
1002
+ if (!fs.existsSync(file)) { failed = `the file ${item.name} is not in the source`; break; }
1003
+ dataUrl = documentOf(file);
1004
+ }
984
1005
  let record = null;
985
1006
  if (item.kind === "record") {
986
1007
  const file = path.join(o.source, item.name);
@@ -1450,7 +1471,7 @@ async function main() {
1450
1471
  found: s.found, missed: s.missed, invented: s.invented, count: s.count,
1451
1472
  metrics: s.metrics, watch: s.watch,
1452
1473
  terms: res.terms || [], ms: res.ms,
1453
- // What a file type was to this run -- "image", "text", "unmapped".
1474
+ // What a file type was to this run -- "image", "document", "text", "unmapped".
1454
1475
  type: item?.kind ?? null,
1455
1476
  // What the model actually said. #248 wants a failure written out as a
1456
1477
  // task an agent can act on, and "missed water" without the reply that