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
@@ -34,7 +34,7 @@
34
34
 
35
35
  import yaml from "./js-yaml.mjs";
36
36
 
37
- import { evaluate, asText, WdlError, } from "./flows/wdl.mjs";
37
+ import { evaluate, asText, templateProblem, WdlError, } from "./flows/wdl.mjs";
38
38
  import { stepsOf as flowStepsOf, actionsByName, readerFor, readsOf, } from "./flows/record.mjs";
39
39
  import { flowApi, } from "./flows/flowApi.mjs";
40
40
  import registerMetrics from "./metrics/builtin.mjs";
@@ -199,12 +199,23 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
199
199
 
200
200
 
201
201
 
202
+
203
+
204
+
205
+
206
+
202
207
   
203
208
 
204
209
 
205
210
 
206
211
 
207
212
 
213
+ /** One kept response of a dynamic Source's item (#596). */
214
+
215
+
216
+
217
+
218
+
208
219
 
209
220
 
210
221
 
@@ -347,17 +358,26 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
347
358
  * at. `filters` are tried in order -- each drop-item narrows the items the
348
359
  * next reads, and the first reject-reply that fires gates the answer.
349
360
  */
350
- /** Which side of Response Format a filter runs on (#405): `text` the raw reply before
351
- it is read into items (rewrite-text / reject-text), `items` the items after
352
- (drop-item, rewrite-item, keep-first, reject-reply). A filter's type decides
353
- its stage; a set belongs to none (#432), and runs its share of filters in
354
- each stage it is linked in. */
361
+ /** Which side of Response Format a filter step sits on (#405): `text`
362
+ before it, on the raw reply (Pre-format), `items` after it (Post-format).
363
+ It also names what a filter reads: the reply's text (rewrite-text /
364
+ reject-text) or the items (drop-item, rewrite-item, keep-first,
365
+ reject-reply). A text filter runs at either stage, an item filter only
366
+ Post-format after a kind that reads items (#557, `stageReads`); a set
367
+ belongs to no stage (#432), and runs its share in each it is linked in. */
355
368
 
356
369
 
357
370
  /** The stage a filter type's scope belongs to: text filters read the raw
358
371
  reply; item, list and answer filters read the items it is read into. */
359
372
  export const stageOfScope = (scope ) => (scope === "text" ? "text" : "items");
360
373
 
374
+ /** What job's [stage] filters read when it answers as [kind] (#557):
375
+ Pre-format the text; Post-format the items after a kind that reads
376
+ items, and the text after any other. A filter runs there when its
377
+ `stageOfScope` is this. */
378
+ export const stageReads = (stage , kind ) =>
379
+ stage === "items" && OUTPUT_KINDS[kind ?? ""]?.offers?.includes("items") ? "items" : "text";
380
+
361
381
  /** A filter stage as the page names it: before the reply is formatted, or after. */
362
382
  export const FILTER_STAGE_LABEL = { text: "Pre-format", items: "Post-format" };
363
383
 
@@ -555,6 +575,10 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
555
575
 
556
576
 
557
577
 
578
+
579
+
580
+
581
+
558
582
 
559
583
 
560
584
 
@@ -586,7 +610,15 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
586
610
 
587
611
 
588
612
 
613
+
614
+
615
+
616
+
589
617
 
618
+
619
+ /** A reply the reply-token limit stopped: the limit the request sent, or
620
+ null where it sent none and the server's own applied. */
621
+
590
622
 
591
623
  /** The transport: a stage's prompt and image in, its reply out. */
592
624
 
@@ -607,6 +639,15 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
607
639
 
608
640
 
609
641
 
642
+
643
+
644
+
645
+
646
+
647
+
648
+
649
+
650
+
610
651
 
611
652
 
612
653
  /** How much of one text a run keeps -- a reply's body, a grader's prompt or
@@ -672,6 +713,7 @@ export function kept(text , max = KEPT_CHARS) {
672
713
 
673
714
 
674
715
 
716
+
675
717
 
676
718
 
677
719
 
@@ -897,6 +939,14 @@ export function kept(text , max = KEPT_CHARS) {
897
939
 
898
940
 
899
941
 
942
+
943
+
944
+
945
+
946
+
947
+
948
+
949
+
900
950
 
901
951
 
902
952
 
@@ -1193,10 +1243,14 @@ const platformOf = (src
1193
1243
 
1194
1244
 
1195
1245
 
1196
-
1197
-
1198
-
1246
+
1247
+
1248
+
1199
1249
 
1250
+
1251
+
1252
+
1253
+
1200
1254
 
1201
1255
 
1202
1256
 
@@ -1686,13 +1740,25 @@ function datasetRules(body ) {
1686
1740
 
1687
1741
 
1688
1742
 
1743
+
1744
+
1745
+
1746
+
1689
1747
 
1690
1748
 
1691
1749
 
1692
1750
 
1751
+
1752
+
1753
+
1754
+
1693
1755
 
1694
1756
 
1695
1757
 
1758
+
1759
+
1760
+
1761
+
1696
1762
 
1697
1763
 
1698
1764
 
@@ -1993,6 +2059,35 @@ function asNumber(v ) {
1993
2059
  // a tuned local engine is a llama.cpp profile with its settings.
1994
2060
  const CONNECTION_TYPES = Object.create(null);
1995
2061
 
2062
+ // ---- documents (#558) ------------------------------------------------------
2063
+ // A file a model's API reads whole, sent unprocessed: the lab does not parse,
2064
+ // render or extract it. It travels where an image does -- the item's
2065
+ // attachment, a data URL -- carrying its name, which some APIs want beside
2066
+ // the bytes. Only a connection type marked `documents` is handed one.
2067
+ const DOCUMENT_TYPES = {
2068
+ ".pdf": { media: "application/pdf", label: "PDF" },
2069
+ };
2070
+
2071
+ /** The DOCUMENT_TYPES entry a file's name is, or null. */
2072
+ function documentTypeOf(name ) {
2073
+ const dot = String(name).lastIndexOf(".");
2074
+ return dot < 0 ? null : DOCUMENT_TYPES[String(name).slice(dot).toLowerCase()] ?? null;
2075
+ }
2076
+
2077
+ /** A document's bytes, base64, as the attachment a stage sends. */
2078
+ function documentUrl(name , media , base64 ) {
2079
+ return `data:${media};name=${encodeURIComponent(name)};base64,${base64}`;
2080
+ }
2081
+
2082
+ /** An attachment's media type, bytes and name (null for an image, which
2083
+ carries none), or null for anything that is not a base64 data URL. */
2084
+ function attachmentOf(dataUrl ) {
2085
+ const m = /^data:([^;,]+)((?:;[^;,=]+=[^;,]*)*);base64,(.*)$/s.exec(dataUrl);
2086
+ if (!m) return null;
2087
+ const name = /;name=([^;,]*)/.exec(m[2] )?.[1];
2088
+ return { media: m[1] , data: m[3] , name: name == null ? null : decodeURIComponent(name) };
2089
+ }
2090
+
1996
2091
  /** The key [key] as the header most endpoints expect, when there is one. */
1997
2092
  const bearer = (key ) => (key ? { Authorization: "Bearer " + key } : {});
1998
2093
 
@@ -2058,6 +2153,7 @@ function ollamaType(id , label , cloud ) {
2058
2153
  const r = j ;
2059
2154
  return { raw: r?.message?.content ?? "", finishReason: r?.done_reason ?? null };
2060
2155
  },
2156
+ cutOff: { reason: "length", limit: (body) => asNumber((body.options )?.num_predict) },
2061
2157
  listModels: (base, key) => listModels(base, cloud ? key : null),
2062
2158
  parseModels: idsFrom,
2063
2159
  ...(cloud ? { url: "https://ollama.com" } : { keyless: true }),
@@ -2105,6 +2201,13 @@ CONNECTION_TYPES.recorded = {
2105
2201
  request: sendsNothing, parseReply: sendsNothing, listModels: sendsNothing, parseModels: () => [],
2106
2202
  };
2107
2203
 
2204
+ /** Whether Setup and the connection pickers list a stored connection. A local
2205
+ type's (Echo, Recorded) is a target step's stand-in now (`localSteps`), so
2206
+ a row of one is kept only for the pipelines and runs that name it. */
2207
+ function listedConnection(conn ) {
2208
+ return !CONNECTION_TYPES[typeOf(conn )]?.local;
2209
+ }
2210
+
2108
2211
  /** A local type's answer to one stage, as a model's would come back. An item
2109
2212
  with no text of its own (Prompt only) is answered from the prompt, and
2110
2213
  that reply is read against nothing -- the prompt is the reply, not an
@@ -2120,9 +2223,19 @@ function localAnswer(conn , text , sent , record
2120
2223
  const chatSchema = (body , schema ) =>
2121
2224
  ({ ...body, response_format: { type: "json_schema", json_schema: { name: "reply", strict: true, schema } } });
2122
2225
 
2226
+ /** An item's attachment as a chat completions content part: an image as
2227
+ image_url, a document as a file part, named, its bytes a data URL. */
2228
+ function chatAttachment(dataUrl ) {
2229
+ const a = attachmentOf(dataUrl);
2230
+ if (!a || a.media.startsWith("image/")) return { type: "image_url", image_url: { url: dataUrl } };
2231
+ return { type: "file", file: { filename: a.name ?? "document", file_data: `data:${a.media};base64,${a.data}` } };
2232
+ }
2233
+
2123
2234
  CONNECTION_TYPES["openai-compatible"] = {
2124
2235
  id: "openai-compatible", label: "OpenAI-compatible",
2125
2236
  description: "OpenAI, OpenRouter, vLLM, LM Studio: any chat completions endpoint.",
2237
+ // A hosted provider wants a key; a server of your own may not.
2238
+ keyOptional: (conn) => !isHostedUrl(String(conn.url ?? "")),
2126
2239
  settings: [
2127
2240
  // A reasoning model refuses a temperature.
2128
2241
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "",
@@ -2145,13 +2258,17 @@ CONNECTION_TYPES["openai-compatible"] = {
2145
2258
  ...(predict == null ? {} : hosted ? { max_completion_tokens: predict } : { max_tokens: predict }),
2146
2259
  messages: [{ role: "user", content: [
2147
2260
  { type: "text", text: prompt },
2148
- ...(dataUrl ? [{ type: "image_url", image_url: { url: dataUrl } }] : []),
2261
+ ...(dataUrl ? [chatAttachment(dataUrl)] : []),
2149
2262
  ] }],
2150
2263
  },
2151
2264
  };
2152
2265
  },
2266
+ // OpenAI's file part; a compatible server that has none refuses the call,
2267
+ // and the refusal is that item's error.
2268
+ documents: true,
2153
2269
  structured: chatSchema,
2154
2270
  parseReply: chatReply,
2271
+ cutOff: { reason: "length", limit: (body) => asNumber(body.max_completion_tokens ?? body.max_tokens) },
2155
2272
  listModels, parseModels: idsFrom,
2156
2273
  };
2157
2274
 
@@ -2174,15 +2291,15 @@ CONNECTION_TYPES.anthropic = {
2174
2291
  answers: ["prompt", "request"],
2175
2292
  speaks: () => "anthropic",
2176
2293
  // The Messages API: the x-api-key and anthropic-version headers, base64
2177
- // image content blocks, and /v1/models for the list.
2294
+ // image and document content blocks, and /v1/models for the list.
2295
+ documents: true,
2178
2296
  request(conn, prompt, dataUrl, base, key) {
2179
2297
  const temp = asNumber(conn.temperature);
2180
2298
  const predict = asNumber(conn.maxTokens) ?? ANTHROPIC_MAX_TOKENS;
2181
2299
  const content = [];
2182
- if (dataUrl) {
2183
- const m = /^data:(image\/[^;]+);base64,(.*)$/s.exec(dataUrl);
2184
- if (m) content.push({ type: "image", source: { type: "base64", media_type: m[1], data: m[2] } });
2185
- }
2300
+ const a = dataUrl ? attachmentOf(dataUrl) : null;
2301
+ if (a?.media.startsWith("image/")) content.push({ type: "image", source: { type: "base64", media_type: a.media, data: a.data } });
2302
+ else if (a) content.push({ type: "document", source: { type: "base64", media_type: a.media, data: a.data } });
2186
2303
  content.push({ type: "text", text: prompt });
2187
2304
  return {
2188
2305
  url: base + "/messages",
@@ -2203,6 +2320,7 @@ CONNECTION_TYPES.anthropic = {
2203
2320
  const raw = (r?.content || []).filter(b => b.type === "text").map(b => b.text).join("");
2204
2321
  return { raw, finishReason: r?.stop_reason ?? null };
2205
2322
  },
2323
+ cutOff: { reason: "max_tokens", limit: (body) => asNumber(body.max_tokens) },
2206
2324
  // A flow's request, keyed as the Messages API is -- what the HTTP endpoint
2207
2325
  // type's Anthropic template sets. [base] is the /v1 base words hang off;
2208
2326
  // the step's path is the whole path production called, so it goes to the
@@ -2227,6 +2345,7 @@ const SPEED_IMAGE = "before", SPEED_CACHE = "off", SPEED_THINKING = "off";
2227
2345
  CONNECTION_TYPES["llama.cpp"] = {
2228
2346
  id: "llama.cpp", label: "llama.cpp",
2229
2347
  description: "A llama-server you run, with its sampler settings.",
2348
+ keyOptional: () => true,
2230
2349
  settings: [
2231
2350
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "" },
2232
2351
  { key: "seed", label: "Seed", control: "text", default: "", hint: "" },
@@ -2285,6 +2404,7 @@ CONNECTION_TYPES["llama.cpp"] = {
2285
2404
  // llama-server takes OpenAI's response_format, a schema and all.
2286
2405
  structured: chatSchema,
2287
2406
  parseReply: chatReply,
2407
+ cutOff: { reason: "length", limit: (body) => asNumber(body.n_predict) },
2288
2408
  listModels, parseModels: idsFrom,
2289
2409
  // A reply that arrived is still a failure when this profile asked for the
2290
2410
  // Speed looping check (today's speedReplyError, now an opt-in setting).
@@ -2511,6 +2631,7 @@ CONNECTION_TYPES.http = {
2511
2631
  ...Object.entries(HTTP_APIS).map(([value, a]) => ({ value, label: a.label }))] },
2512
2632
  ],
2513
2633
  answers: ["request"],
2634
+ keyOptional: (conn) => conn.auth === "none",
2514
2635
  speaks(conn) {
2515
2636
  const api = String(conn.requestFormat || "") || httpApiFor(originBase(conn.url));
2516
2637
  return HTTP_APIS[api] && api !== "raw" ? api : null;
@@ -2658,6 +2779,18 @@ function connectionReply(conn , j )
2658
2779
  }
2659
2780
 
2660
2781
  /** Why a reply [conn] received is a failure though it arrived, or null. */
2782
+ /** How a reply finished, as a stage keeps it (#597): the reason [conn]'s
2783
+ API gave, and -- where that reason is its type's reply-token limit -- the
2784
+ limit [body], the request sent, carried. A type that says no reason
2785
+ keeps nothing. */
2786
+ function replyFinish(conn , body , finishReason ) {
2787
+ if (!finishReason) return {};
2788
+ const cut = typeEntry(conn).cutOff;
2789
+ if (!cut || finishReason !== cut.reason) return { finish: finishReason };
2790
+ const limit = body && typeof body === "object" ? cut.limit(body ) : null;
2791
+ return { finish: finishReason, stopped: { limit } };
2792
+ }
2793
+
2661
2794
  function connectionReplyProblem(conn , raw , finishReason ) {
2662
2795
  return typeEntry(conn).replyProblem?.(conn, raw, finishReason) ?? null;
2663
2796
  }
@@ -2797,7 +2930,7 @@ function readCase(kase , res , plain = fa
2797
2930
  }
2798
2931
  return r ? [r ] : [];
2799
2932
  });
2800
- const s = scoreOf(metrics) ?? { pass: true, score: null, metrics };
2933
+ const s = erroredScore(scoreOf(metrics), res.error) ?? { pass: !res.error, score: null, metrics };
2801
2934
  const counted = metrics.filter(r => r.weight !== 0);
2802
2935
  return { ...s, metrics, found: s.found ?? [], missed: s.missed ?? [], invented: s.invented ?? [],
2803
2936
  discarded: !!res.error, count: res.error ? 0 : (res.terms || []).length,
@@ -3183,7 +3316,8 @@ async function runPipeline(stages , dataUrl , cal
3183
3316
  total += res.ms;
3184
3317
  transcript.push({ n: i + 1, kind, conn: res.conn ?? null, withImage, sent: res.sent ?? sent,
3185
3318
  got: res.raw ?? "", ms: res.ms, error: res.error, ...(res.said != null ? { said: res.said } : {}),
3186
- ...(res.body != null ? { body: kept(res.body) } : {}) });
3319
+ ...(res.body != null ? { body: kept(res.body) } : {}),
3320
+ ...(res.finish ? { finish: res.finish } : {}), ...(res.stopped ? { stopped: res.stopped } : {}) });
3187
3321
  if (res.error) return result({ error: named(res.error) });
3188
3322
  // A stage that failed is the end of the job: nothing after it is asked.
3189
3323
  // Handed nothing, the next stage still answers -- about an empty list --
@@ -3195,12 +3329,23 @@ async function runPipeline(stages , dataUrl , cal
3195
3329
  const pre = applyFilterSet(st.before || [], { body: res.raw ?? "", items: [], instruction: against }, "text");
3196
3330
  const by = (filter , stage , rejected ) => ({ filter, stage, job: i + 1, rejected });
3197
3331
  if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`), filteredBy: by(pre.reject, "text", true) });
3332
+ if (pre.rewrote) transcript[transcript.length - 1] .filtered = { got: pre.body, by: pre.rewrote };
3198
3333
  const read = reader.read(pre.body, { echo: against, parsing: opts.parsing, settings: st.settings });
3199
3334
  if (read.error) return result({ error: named(read.error) });
3200
3335
  const mod = applyModifiers(mods[i], kind, read.value, { instruction: against, body: read.body ?? res.raw ?? "" });
3201
3336
  if (mod.reject) {
3202
3337
  return result({ error: named(`discarded: ${mod.reject}`), ...(mod.by ? { filteredBy: by(mod.by, "items", true) } : {}) });
3203
3338
  }
3339
+ // A kind that reads no items runs its Post-format filters on its text,
3340
+ // once read (#557); what they leave is what is graded.
3341
+ if (st.after?.length && typeof mod.value === "string") {
3342
+ const post = applyFilterSet(st.after, { body: mod.value, items: [], instruction: against }, "text");
3343
+ if (post.reject != null) return result({ error: named(`discarded: ${post.reject}`), filteredBy: by(post.reject, "items", true) });
3344
+ if (post.rewrote) {
3345
+ transcript[transcript.length - 1] .filtered = { got: post.body, by: [...(pre.rewrote || []), ...post.rewrote] };
3346
+ mod.value = post.body;
3347
+ }
3348
+ }
3204
3349
  // A filter that dropped the last item: what a zero result is explained by.
3205
3350
  const emptied = mod.by && reader.terms && !reader.terms(mod.value).length ? { filteredBy: by(mod.by, "items", false) } : {};
3206
3351
  const nothing = handsOn ? reader.handsOn?.(mod.value) : null;
@@ -3209,7 +3354,9 @@ async function runPipeline(stages , dataUrl , cal
3209
3354
  held = { kind, value };
3210
3355
  const gone = [...(read.dropped || []), ...mod.dropped];
3211
3356
  echoed = (read.echoed || 0) + mod.dropped.filter(d => d.by === ECHO).length;
3212
- done.push({ reply: res.raw ?? "", rendered: reader.render?.(value) || {} });
3357
+ // {reply} and {stageN} hand on the text as its filters left it -- what
3358
+ // was graded -- never the raw reply a filter cleaned (#557).
3359
+ done.push({ reply: transcript[transcript.length - 1] .filtered?.got ?? res.raw ?? "", rendered: reader.render?.(value) || {} });
3213
3360
  if (!handsOn) {
3214
3361
  dropped = gone;
3215
3362
  return result({ echoed, dropped, ...emptied });
@@ -3309,7 +3456,7 @@ function applyModifiers (list , kind ,
3309
3456
  // 16: a target names an AI Connection and, optionally, a Connection Profile,
3310
3457
  // where it named one Target profile; a run's table is keyed by the pair
3311
3458
  // (answerKey) -- the Target profile split in the store as it opens (#445).
3312
- const PIPELINE_VERSION = 17 ;
3459
+ const PIPELINE_VERSION = 18 ;
3313
3460
 
3314
3461
  // Plain objects, so an entry is added by assignment and a reader never needs
3315
3462
  // to know which registered it.
@@ -3591,9 +3738,10 @@ function splitProfile(p )
3591
3738
  }
3592
3739
 
3593
3740
  /** [conn] with [profile]'s settings over it: the one set of settings a
3594
- request is built from, the shape a Target profile had. A profile sets
3595
- no connection field. */
3596
- function mergeAnswer(conn , profile ) {
3741
+ request is built from, the shape a Target profile had. A profile -- or
3742
+ (Default)'s settings, which are one without its id and name -- sets no
3743
+ connection field. */
3744
+ function mergeAnswer(conn , profile ) {
3597
3745
  const out = { ...conn };
3598
3746
  const scoped = connectionScoped(typeOf(conn ));
3599
3747
  for (const [k, v] of Object.entries(profile ?? {})) {
@@ -3610,7 +3758,7 @@ function mergeAnswer(conn , profile
3610
3758
 
3611
3759
 
3612
3760
  /** The key a run's table holds [connection] under, with [profile] -- or with
3613
- none, the lab's defaults. A grader names its connection bare, its id alone,
3761
+ none, (Default)'s settings. A grader names its connection bare, its id alone,
3614
3762
  so it never meets a target's pair (see `answersOf`). */
3615
3763
  const answerKey = (connection , profile ) => `${connection}.${profile ?? ""}`;
3616
3764
 
@@ -3645,9 +3793,13 @@ function targetAnswerKey(doc
3645
3793
  * answer key resolved to the settings it sends -- the profile's connection
3646
3794
  * where it names one, else the key's, with the profile's settings over it.
3647
3795
  * A grader's bare id resolves as the Target profile it named did: its
3648
- * connection under the profile split from it. Null where any part is gone.
3796
+ * connection under the profile split from it. One that names no profile, or
3797
+ * a grader with none split beside it, takes [fallback] instead -- (Default)'s
3798
+ * settings, which sit under no named profile, so a profile is still what it
3799
+ * sets (Setup's Used by counts the same, `defaultUses`). Null where any part
3800
+ * is gone.
3649
3801
  */
3650
- function answersOf(connections , profiles ) {
3802
+ function answersOf(connections , profiles , fallback ) {
3651
3803
  const connection = (id ) => connections.find(c => c?.id === id) ?? null;
3652
3804
  const profile = (id ) => profiles.find(p => p?.id === id) ?? null;
3653
3805
  const split = (id ) => {
@@ -3660,7 +3812,7 @@ function answersOf(connections , profiles
3660
3812
  const p = pid ? profile(pid) : null;
3661
3813
  if (pid && !p) return null;
3662
3814
  const c = connection(isRef(p?.connection) ? p .connection .id : at.connection);
3663
- return c ? mergeAnswer(c, p) : null;
3815
+ return c ? mergeAnswer(c, p ?? fallback) : null;
3664
3816
  };
3665
3817
  return { connection, profile, split, resolve };
3666
3818
  }
@@ -3731,16 +3883,17 @@ function onlyFields(obj , at , allowed , bad
3731
3883
 
3732
3884
  // A Source is referenced, with the two numbers that decide how many items a
3733
3885
  // run over it is: the first N files, and how many times over.
3734
- // A folder of files: images sent as images, text files as {text}. The
3735
- // extensions are server.py's FILE_TYPES, which the server holds uploads to.
3886
+ // A folder of files: images sent as images, documents whole (DOCUMENT_TYPES),
3887
+ // text files as {text}. The extensions are server.py's FILE_TYPES, which the
3888
+ // server holds uploads to.
3736
3889
  const FILE_LIBRARY_TEXT = [".txt", ".md", ".csv"] ;
3737
3890
  SOURCE_TYPES.files = {
3738
3891
  label: "File Library",
3739
3892
  icon: "folder",
3740
- description: "A folder of images and text files you upload.",
3893
+ description: "A folder of images, PDFs and text files you upload.",
3741
3894
  group: "Files",
3742
3895
  noun: "file",
3743
- uploads: [".jpg", ".jpeg", ".png", ".heic", ".heif", ".dng", ".tif", ".tiff", ...FILE_LIBRARY_TEXT],
3896
+ uploads: [".jpg", ".jpeg", ".png", ".heic", ".heif", ".dng", ".tif", ".tiff", ...Object.keys(DOCUMENT_TYPES), ...FILE_LIBRARY_TEXT],
3744
3897
  sendsImage: true,
3745
3898
  hasActions: false,
3746
3899
  itemText: name => FILE_LIBRARY_TEXT.some(ext => name.toLowerCase().endsWith(ext)),
@@ -3757,7 +3910,9 @@ SOURCE_TYPES.workflow = {
3757
3910
  icon: "workflow",
3758
3911
  description: "A cloud flow, with records of its steps' calls from its run history.",
3759
3912
  group: "Workflows",
3760
- noun: "record",
3913
+ // Each item reads as a call, as the Source's Calls group names them, though
3914
+ // the file holding one is a record.
3915
+ noun: "call",
3761
3916
  uploads: [".json"],
3762
3917
  sendsImage: false,
3763
3918
  hasActions: true,
@@ -3810,15 +3965,15 @@ SOURCE_TYPES.dynamic = {
3810
3965
  };
3811
3966
 
3812
3967
  // HTTP GET (docs/dynamic-sources.md § 1): the safest pull provider. It fetches
3813
- // a URL through the relay server-side -- Snapshot now freezes one Call into the
3814
- // Source, Live produces one fresh at submit -- so the page drives only the
3968
+ // a URL through the relay server-side -- Fetch now keeps a response, Live
3969
+ // data fetches it again at submit -- so the page drives only the
3815
3970
  // create dialog and the Call labels; the fetch, redaction and token-refusal are
3816
3971
  // the server's (its SOURCE_PROVIDERS mirror in server.py).
3817
3972
  DYNAMIC_PROVIDERS["http-get"] = { // provider: the HTTP GET adapter's own id
3818
3973
  id: "http-get", // provider: its id, the row's config.provider
3819
3974
  label: "HTTP GET",
3820
3975
  icon: "square-code",
3821
- noun: "call",
3976
+ noun: "response",
3822
3977
  tier: "pull",
3823
3978
  config: ["url"],
3824
3979
  configProblems(config){
@@ -3846,8 +4001,8 @@ const API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
3846
4001
  DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3847
4002
  id: "api", // provider: its id, the row's config.provider
3848
4003
  label: "API call",
3849
- icon: "square-code",
3850
- noun: "call",
4004
+ icon: "arrow-left-right",
4005
+ noun: "response",
3851
4006
  tier: "pull",
3852
4007
  config: ["method", "url", "reqHeaders", "body", "connection"],
3853
4008
  configProblems(config){
@@ -3876,7 +4031,7 @@ DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3876
4031
  // or an Upload… by hand, formalised as a provider. The lab fetches and executes
3877
4032
  // nothing: a Supplied item is named by the id the caller gives it (its file
3878
4033
  // name), which is what joins it to a case. It has no config and no server-side
3879
- // production, so no Snapshot now and no live; its Calls are the items handed in,
4034
+ // production, so no Fetch now and no live data; its Calls are the items handed in,
3880
4035
  // redacted on the way in like any other (server.py's take_dynamic). A hosted run
3881
4036
  // grades those Calls; a solely-Supplied Source with none is run only via the CLI.
3882
4037
  DYNAMIC_PROVIDERS["supplied"] = { // provider: the Supplied adapter's own id
@@ -3906,6 +4061,7 @@ DYNAMIC_PROVIDERS["file"] = { // provider: the File on disk adapter's own id
3906
4061
  label: "File on disk",
3907
4062
  icon: "file",
3908
4063
  noun: "file",
4064
+ produceLabel: "Read now",
3909
4065
  tier: "local",
3910
4066
  config: ["path"],
3911
4067
  configProblems(config){
@@ -3929,6 +4085,7 @@ DYNAMIC_PROVIDERS["command"] = { // provider: the Command adapter's own id
3929
4085
  label: "Command",
3930
4086
  icon: "terminal",
3931
4087
  noun: "call",
4088
+ produceLabel: "Run now",
3932
4089
  tier: "local",
3933
4090
  config: ["command", "args"],
3934
4091
  configProblems(config){
@@ -4011,11 +4168,14 @@ CONTENT_TYPES.source = {
4011
4168
  description: "Every file or record in a Source from the Library.",
4012
4169
  // `live` is a dynamic Source's per-run snapshot-vs-live override
4013
4170
  // (docs/dynamic-sources.md); meaningless for another kind, ignored there.
4014
- fields: ["type", "ref", "first", "loops", "live"],
4171
+ fields: ["type", "ref", "first", "loops", "live", "pin"],
4015
4172
  runFields: ["files", "revs"],
4016
4173
  validate(c, ctx, bad){
4017
4174
  if (!isRef(c.ref)) return void bad.push("a Source content has to name its Source as { id, name }");
4018
4175
  if (c.live != null && typeof c.live !== "boolean") bad.push("a Source content's live is true or false");
4176
+ if (c.pin != null && !(isObj(c.pin) && isStr(c.pin.name) && c.pin.name && Number.isInteger(c.pin.n) && c.pin.n >= 0)) {
4177
+ bad.push("a Source content's pin names a response as { name, n }");
4178
+ }
4019
4179
  if (c.first != null && !(Number.isInteger(c.first) && c.first > 0)) {
4020
4180
  bad.push("Run first wants a positive number, or empty for all");
4021
4181
  }
@@ -4116,7 +4276,7 @@ LEGACY_TESTS.single = {
4116
4276
  const raws = [], terms = [];
4117
4277
  for (const r of ress) {
4118
4278
  if (!r) continue;
4119
- raws.push(r.transcript?.[r.transcript.length - 1]?.got ?? r.raw ?? "");
4279
+ raws.push(gradedText(r));
4120
4280
  terms.push(...(r.terms || []));
4121
4281
  }
4122
4282
  const s = scoreSingle(t, terms, raws, !(kind != null && OUTPUT_KINDS[kind]?.terms));
@@ -4181,11 +4341,32 @@ function metricsProblems(list , at , bad ) {
4181
4341
  /** Whether any of [list] asks a grader. */
4182
4342
  const gradedIn = (list ) => Array.isArray(list) && list.some((m ) => !!METRICS[m?.type]?.graded);
4183
4343
 
4344
+ /** Whether [error] is a reply the job's rules threw away, as the Discarded
4345
+ metric reads it: a result that came back, where any other error is a
4346
+ request that never did. */
4347
+ const thrownAway = (error ) => !!error && error.startsWith("discarded: ");
4348
+
4349
+ /** [s] as an errored result's score: whatever its metrics read, a result
4350
+ with no reply fails, and only a discarded reply is left to them (#544) --
4351
+ or a refused call, to a case that scores whether it was (#559). */
4352
+ function erroredScore (s , error ) {
4353
+ const asksRefusal = !!s?.metrics?.some(r => r.weight !== 0 && METRICS[r.type]?.readsRefusal);
4354
+ return s && error != null && !thrownAway(error) && !asksRefusal ? { ...s, pass: false } : s;
4355
+ }
4356
+
4357
+ /** The reply a metric grades and Results shows (#546): the last stage's
4358
+ reply after its filters (#557), or the raw reply. */
4359
+ function gradedText(res ) {
4360
+ const last = res?.transcript?.at(-1);
4361
+ return last?.filtered?.got ?? last?.got ?? res?.raw ?? "";
4362
+ }
4363
+
4184
4364
  /** What a metric reads of one result. */
4185
4365
  function metricInput(res , kase , more ) {
4186
4366
  const last = res.transcript?.at(-1);
4187
- const text = last?.got ?? res.raw ?? "";
4188
- return { text, said: last?.said ?? null, terms: res.terms ?? [], replies: [text], plain: !!more.plain,
4367
+ const text = gradedText(res);
4368
+ // `of: "said"` reads the reply as it came back, before any filter.
4369
+ return { text, said: last?.said ?? (last?.filtered ? last.got ?? null : null), terms: res.terms ?? [], replies: [text], plain: !!more.plain,
4189
4370
  asked: last?.sent ?? null,
4190
4371
  // A case that has been given its own expected reply ("Take B as
4191
4372
  // expected") is compared with that; otherwise the Source's recorded
@@ -4201,7 +4382,7 @@ function runInput(ress , plain )
4201
4382
  let ms = 0;
4202
4383
  for (const r of ress) {
4203
4384
  if (!r) continue;
4204
- replies.push(r.transcript?.[r.transcript.length - 1]?.got ?? r.raw ?? "");
4385
+ replies.push(gradedText(r));
4205
4386
  terms.push(...(r.terms || []));
4206
4387
  ms += r.ms ?? 0;
4207
4388
  }
@@ -4217,6 +4398,19 @@ function readMetric(m , input , ctx )
4217
4398
  const label = (isStr(m.metric) && m.metric.trim()) || entry?.label || m.type;
4218
4399
  if (!entry) return { type: m.type, label, weight, pass: false, score: 0, reason: `"${m.type}" is not a metric this lab has` };
4219
4400
  const read = m.of === "said" ? { ...input, text: input.said ?? input.text } : input;
4401
+ // An errored result has no reply to read (#544): a metric that reads one
4402
+ // fails on the error, and a grader is never asked about the empty reply.
4403
+ if (input.error != null && !(entry.readsResult && (thrownAway(input.error) || entry.readsRefusal))) {
4404
+ const fail = { type: m.type, label, weight, pass: false, score: 0, reason: input.error };
4405
+ if (entry.graded) return fail;
4406
+ // What it would have missed still counts toward a run's totals.
4407
+ let o = null;
4408
+ try { o = entry.score(read, m, {}); } catch { o = null; }
4409
+ if (!o) return null;
4410
+ if (typeof (o ).then === "function") return fail;
4411
+ const { found, missed, invented } = o ;
4412
+ return { ...fail, ...(found ? { found } : {}), ...(missed ? { missed } : {}), ...(invented ? { invented } : {}) };
4413
+ }
4220
4414
  const done = (o ) => {
4221
4415
  if (!o) return null;
4222
4416
  const turned = m.not ? { ...o, pass: !o.pass, score: entry.counts ? -o.score : 1 - o.score, reason: `not: ${o.reason}` } : o;
@@ -4249,6 +4443,18 @@ function readMetric(m , input , ctx )
4249
4443
  } catch (e) { return timed(failed(e)); }
4250
4444
  }
4251
4445
 
4446
+ /** What stops [grader] being asked, where the lookups can say: a grader is
4447
+ asked words, and needs a model to ask. */
4448
+ function graderProblems(grader , ctx , bad ) {
4449
+ const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4450
+ if (grader && ctx.profiles && !p) bad.push(`Grader ${grader.name || grader.id} not found`);
4451
+ else if (p) {
4452
+ const type = CONNECTION_TYPES[typeOf(p )];
4453
+ if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4454
+ else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4455
+ }
4456
+ }
4457
+
4252
4458
  /** An eval's score from its metrics' readings, the way [mode] says: all must
4253
4459
  pass, or weighted points against [threshold]. What a case metric found,
4254
4460
  missed and invented is carried up, for a run's totals. Null for none. */
@@ -4277,7 +4483,7 @@ async function readMetrics(list , input , ctx
4277
4483
  const r = await readMetric(m, input, ctx);
4278
4484
  if (r) metrics.push(r);
4279
4485
  }
4280
- return scoreOf(metrics, mode, threshold);
4486
+ return erroredScore(scoreOf(metrics, mode, threshold), input.error);
4281
4487
  }
4282
4488
 
4283
4489
  /** [list] read over a whole run, synchronously: each metric over the run's
@@ -4331,14 +4537,7 @@ LEGACY_TESTS.metrics = {
4331
4537
  // The eval's own grader, or the lab's.
4332
4538
  const grader = isRef(t.grader) ? t.grader : isRef(ctx.grader) ? ctx.grader : null;
4333
4539
  if (gradedIn(t.metrics) && !grader) bad.push("a model-graded metric needs a grader: pick one on the eval, or make a connection the lab's grader");
4334
- // A grader is asked words, and needs a model to ask.
4335
- const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4336
- if (grader && ctx.profiles && !p) bad.push(`Connection ${grader.name || grader.id} not found`);
4337
- else if (p) {
4338
- const type = CONNECTION_TYPES[typeOf(p )];
4339
- if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4340
- else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4341
- }
4540
+ graderProblems(grader, ctx, bad);
4342
4541
  },
4343
4542
  rules: t => (Array.isArray(t.metrics) ? t.metrics : []).map((m , x ) => ({
4344
4543
  key: `m${x}`, label: (isStr(m.metric) && m.metric) || METRICS[m.type]?.label || m.type,
@@ -4450,6 +4649,11 @@ EVAL_TYPES.group = {
4450
4649
  if (held && t.pin != null && typeof held.versions === "number" && t.pin > held.versions) {
4451
4650
  bad.push(`${g.name || g.id} has no version ${t.pin}`);
4452
4651
  }
4652
+ // The grader the group asks: the run's copy of the link carries it, a
4653
+ // pipeline's reads it from the group as the lab lists it. One that is
4654
+ // gone would fail every item on a metric that cannot run (#545).
4655
+ const grader = isRef(t.grader) ? t.grader : isRef(held?.grader) ? held .grader : null;
4656
+ graderProblems(grader, ctx, bad);
4453
4657
  return;
4454
4658
  }
4455
4659
  if (!isObj(own)) return void bad.push("an eval links a Library group, or holds its own");
@@ -4466,14 +4670,7 @@ EVAL_TYPES.group = {
4466
4670
  // The group's own grader, or the lab's.
4467
4671
  const grader = isRef(own.grader) ? own.grader : isRef(ctx.grader) ? ctx.grader : null;
4468
4672
  if (gradedIn(own.every) && !grader) bad.push("a model-graded metric needs a grader: pick one on the eval, or make a connection the lab's grader");
4469
- // A grader is asked words, and needs a model to ask.
4470
- const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4471
- if (grader && ctx.profiles && !p) bad.push(`Connection ${grader.name || grader.id} not found`);
4472
- else if (p) {
4473
- const type = CONNECTION_TYPES[typeOf(p )];
4474
- if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4475
- else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4476
- }
4673
+ graderProblems(grader, ctx, bad);
4477
4674
  },
4478
4675
  rules: t => ownList(t).map((m , x ) => ({
4479
4676
  key: `m${x}`, label: (isStr(m.metric) && m.metric) || METRICS[m.type]?.label || m.type,
@@ -4864,8 +5061,12 @@ STEP_TYPES.httpRequest = {
4864
5061
  }
4865
5062
  }
4866
5063
  if (isStr(step.prompt)) {
4867
- try { api.place(step.body, step ); }
4868
- catch (e) { bad.push(`${at}: ${(e ).message}`); }
5064
+ // Built here as each call builds it, so an expression none of them
5065
+ // could read is refused before the run rather than failing every call.
5066
+ try {
5067
+ const why = templateProblem([step.path, step.query, api.place(step.body, step )]);
5068
+ if (why) bad.push(`${at}: the request could not be built -- ${why}`);
5069
+ } catch (e) { bad.push(`${at}: ${(e ).message}`); }
4869
5070
  }
4870
5071
  },
4871
5072
  };
@@ -4890,7 +5091,6 @@ STEP_TYPES.echo = {
4890
5091
  // the flow reads one. No profile, no prompt -- the record is the reply.
4891
5092
  STEP_TYPES.recorded = {
4892
5093
  label: "Recorded", slot: "target", asks: "nothing", local: true, replaces: "recorded",
4893
- description: "Pre-recorded from previous workflow runs, replays only.",
4894
5094
  firstJobOnly: "a call's recorded reply is job 1's",
4895
5095
  in: "item", out: "text",
4896
5096
  apply: "runPipeline",
@@ -5033,6 +5233,22 @@ function withContent (doc , content )
5033
5233
  return { ...doc, jobs: [withStep(first, "attachContent", content ? { type: "attachContent", content } : null), ...rest] };
5034
5234
  }
5035
5235
 
5236
+ /**
5237
+ * Whether target [i]'s words in job [k] have the item's text joined after
5238
+ * them (#597): job 1, a target that asks a prompt, text content -- a Source
5239
+ * of text items, or inline text, as its CONTENT_TYPES entry says -- and no
5240
+ * {text} in the words to place it (textPrompt). A later job's reply goes
5241
+ * only where a token places it, and an image or a document is sent as a part
5242
+ * of its own, so neither is joined. [ctx] reads a Source as validation does.
5243
+ */
5244
+ function contentJoined(doc , i , k ,
5245
+ ctx = {}) {
5246
+ if (k !== 0 || targetEntryOf(doc, i, k)?.asks !== "prompt") return false;
5247
+ if (String(targetStepOf(doc, i, k)?.prompt ?? "").includes("{text}")) return false;
5248
+ const content = contentOf(doc);
5249
+ return !!content && (CONTENT_TYPES[content.type]?.text?.(content, ctx) ?? null) != null;
5250
+ }
5251
+
5036
5252
  /** A document's targets, in column order. */
5037
5253
  function targetsOf(doc ) {
5038
5254
  return Array.isArray(doc?.targets) ? doc .targets : [];
@@ -5252,6 +5468,15 @@ function stageModifiers(job , resolve
5252
5468
  return out;
5253
5469
  }
5254
5470
 
5471
+ /** A job's Post-format filters for a run when its kind reads no items
5472
+ (#557), in document order: every link's on its `items` filters step, which
5473
+ a run applies to the text after the kind reads it. A kind that reads items
5474
+ has none here -- `stageModifiers` runs its Post-format links. */
5475
+ function afterFilters(job , resolve ) {
5476
+ if (stageReads("items", outOf(job)?.kind) !== "text") return [];
5477
+ return (filterStepOf(job, "items")?.filterSets || []).flatMap(link => linkFilters(link, resolve));
5478
+ }
5479
+
5255
5480
  /** A job's Pre-format (text) filters for a run (#405), in document order:
5256
5481
  the filters of every link on its `text` filters step, which a run applies to
5257
5482
  the raw reply before the kind reads it into items. [resolve] gives a Library
@@ -5419,6 +5644,12 @@ function versionProblem(doc ) {
5419
5644
 
5420
5645
 
5421
5646
 
5647
+
5648
+
5649
+
5650
+
5651
+
5652
+
5422
5653
 
5423
5654
 
5424
5655
  /**
@@ -5710,7 +5941,8 @@ function ownBodiesNow(doc ) {
5710
5941
 
5711
5942
  function upgradePipeline (doc , ctx = {}) {
5712
5943
  // A current document is read as it is, but for a profile reference the Runs
5713
- // tab saved whole (see profileRefs), which is cut back, and a step on a
5944
+ // tab saved whole (see profileRefs), which is cut back, a Read Reply's
5945
+ // leftover `preamble: false` (withoutLeftoverPreamble), and a step on a
5714
5946
  // profile a target step stands in for (localSteps).
5715
5947
  if (isObj(doc) && doc.version === PIPELINE_VERSION) {
5716
5948
  let out = doc ;
@@ -5718,12 +5950,16 @@ function upgradePipeline (doc , ctx = {}) {
5718
5950
  out = clone(out) ;
5719
5951
  profileRefs(out);
5720
5952
  }
5721
- return localSteps(ownBodiesNow(out), ctx) ;
5953
+ return localSteps(ownBodiesNow(withoutLeftoverPreamble(out)), ctx) ;
5722
5954
  }
5723
- if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].includes(doc.version )) return doc;
5955
+ if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(doc.version )) return doc;
5956
+ // Every version reads as 17 first, then as 18: a saved-mode Source content
5957
+ // pins one response (savedPinned).
5958
+ const eighteen = (d ) => localSteps(ownBodiesNow(savedPinned(d, ctx)), ctx) ;
5959
+ if (doc.version === 17) return eighteen(cutRefs(clone(doc) ));
5724
5960
  // Every version reads as 16 first, then as 17: its requests read by their
5725
5961
  // job's HTTP API (requestsOnJobApi).
5726
- const seventeen = (d ) => localSteps(ownBodiesNow(requestsOnJobApi(d)), ctx) ;
5962
+ const seventeen = (d ) => eighteen(requestsOnJobApi(d));
5727
5963
  if (doc.version === 16) return seventeen(cutRefs(clone(doc) ));
5728
5964
  // Every version reads as 15 first, then as 16: its Target profiles split
5729
5965
  // into AI Connections and Connection Profiles (answersFromProfiles).
@@ -5769,11 +6005,13 @@ function filtersFromCleaning(next ) {
5769
6005
  const isFilters = (st ) => isObj(st) && st.type === "filters";
5770
6006
  next.jobs = next.jobs.map((job ) => {
5771
6007
  if (!isObj(job) || !Array.isArray(job.steps)) return job;
5772
- const steps = job.steps ;
5773
- const readReply = steps.find(st => isObj(st) && st.type === "readReply") ;
6008
+ const readReply = job.steps.find((st ) => isObj(st) && st.type === "readReply") ;
5774
6009
  const preamble = !!(readReply && isObj(readReply.out) && readReply.out.preamble === true);
6010
+ // Stripped from every Read Reply, `false` included, before the job is
6011
+ // asked whether it has anything to migrate (#604).
6012
+ const steps = (job.steps ).map(withoutPreamble);
5775
6013
  const stale = steps.some(st => isFilters(st) && (st ).stage == null);
5776
- if (!steps.some(isCleaning) && !preamble && !stale) return job;
6014
+ if (!steps.some(isCleaning) && !preamble && !stale) return { ...job, steps };
5777
6015
  const jid = isStr(job.id) ? job.id : "job";
5778
6016
 
5779
6017
  // Walk the Responses steps, turning each into a chunk of items filters
@@ -5802,11 +6040,6 @@ function filtersFromCleaning(next ) {
5802
6040
  const out = [];
5803
6041
  let placed = false;
5804
6042
  for (const st of steps) {
5805
- if (isObj(st) && st.type === "readReply" && isObj(st.out) && "preamble" in st.out) {
5806
- const { preamble: _p, ...rest } = st.out ;
5807
- out.push({ ...st, out: rest });
5808
- continue;
5809
- }
5810
6043
  if (isCleaning(st) || isFilters(st)) {
5811
6044
  if (!placed) { if (textStep) out.push(textStep); if (itemsStep) out.push(itemsStep); placed = true; }
5812
6045
  continue;
@@ -5819,6 +6052,30 @@ function filtersFromCleaning(next ) {
5819
6052
  return next;
5820
6053
  }
5821
6054
 
6055
+ /** [st] without its List `preamble` setting, when it is a Read Reply holding
6056
+ one; [st] itself otherwise. */
6057
+ function withoutPreamble(st ) {
6058
+ if (!isObj(st) || st.type !== "readReply" || !isObj(st.out) || !("preamble" in st.out)) return st;
6059
+ const { preamble: _p, ...out } = st.out ;
6060
+ return { ...st, out };
6061
+ }
6062
+
6063
+ /** Whether a current document still carries a `preamble: false` that an
6064
+ upgrade before #604 left behind. It did nothing, so it is cut as the
6065
+ document is read; a `preamble: true` the lab never wrote at this version is
6066
+ left for validation to refuse. */
6067
+ function leftoverPreamble(doc ) {
6068
+ return Array.isArray(doc.jobs) && doc.jobs.some((job ) => isObj(job) && Array.isArray(job.steps)
6069
+ && job.steps.some((st ) => isObj(st) && st.type === "readReply" && isObj(st.out) && st.out.preamble === false));
6070
+ }
6071
+
6072
+ /** [doc] with each Read Reply's leftover `preamble: false` cut (leftoverPreamble). */
6073
+ function withoutLeftoverPreamble(doc ) {
6074
+ if (!leftoverPreamble(doc)) return doc;
6075
+ return { ...doc, jobs: doc.jobs.map((job ) => (!isObj(job) || !Array.isArray(job.steps) ? job
6076
+ : { ...job, steps: job.steps.map((st ) => (isObj(st) && isObj(st.out) && st.out.preamble === false ? withoutPreamble(st) : st)) })) };
6077
+ }
6078
+
5822
6079
  /** [doc] with no Metrics eval holding the `case` metric: a dataset's cases
5823
6080
  are metrics now (dataset body version 5), which an eval naming the
5824
6081
  dataset adds for each item, so the case's own metrics carry what that
@@ -5872,6 +6129,24 @@ function localSteps(doc , ctx ) {
5872
6129
  return next;
5873
6130
  }
5874
6131
 
6132
+ /**
6133
+ * Version 17 to 18 (#596): Saved response pins one response. A Source content
6134
+ * graded as saved -- `live` false, or unset over a Source whose default is
6135
+ * saved, which [ctx] says -- pins the Source's newest successful response
6136
+ * (owner), so a Source holding several grades one where it graded all. Live
6137
+ * data, a content that is not a Source's, or one [ctx] cannot say of, pins
6138
+ * none; a run document carries its files, and needs none.
6139
+ */
6140
+ function savedPinned(next , ctx ) {
6141
+ next.version = PIPELINE_VERSION;
6142
+ const content = contentOf(next ) ;
6143
+ if (!isObj(content) || content.type !== "source" || content.live === true || content.pin != null
6144
+ || isObj(next.profiles) || !isRef(content.ref) || !ctx.pinFor) return next;
6145
+ const pin = ctx.pinFor(content.ref.id, typeof content.live === "boolean" ? content.live : undefined);
6146
+ if (pin) content.pin = { name: pin.name, n: pin.n };
6147
+ return next;
6148
+ }
6149
+
5875
6150
  /**
5876
6151
  * Version 16 to 17 (#477): a request step has no HTTP API of its own; its
5877
6152
  * job's flow step says it. A step that named another -- Any endpoint over an
@@ -6082,6 +6357,10 @@ function validatePipeline(input , ctx = {}) {
6082
6357
  ? (c.sources?.(content.ref?.id)?.files ?? content.files ?? null) : null;
6083
6358
  const nonText = (contentFiles || []).map(f => String((f )?.name ?? f))
6084
6359
  .find(n => !/\.(?:txt|md|csv)$/i.test(n));
6360
+ // A document goes whole to the API, so only a type that reads one is
6361
+ // handed it -- never sent as an image or as text it is not.
6362
+ const docType = (contentFiles || []).map(f => documentTypeOf(String((f )?.name ?? f)))
6363
+ .find(Boolean) ?? null;
6085
6364
  targets.forEach((_, i) => {
6086
6365
  const conns = doc.jobs.map((_ , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k)) : lookup(targetAnswerOf(doc, i, k) )));
6087
6366
  conns.forEach((p , k ) => {
@@ -6111,6 +6390,10 @@ function validatePipeline(input , ctx = {}) {
6111
6390
  const name = targetAnswerOf(doc, i, k)?.connection.name || type .label;
6112
6391
  bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't send an ${HTTP_APIS[api]?.label ?? api} request`);
6113
6392
  }
6393
+ if (docType && type && !type.documents && sendsImage(doc.jobs[k])) {
6394
+ const name = targetAnswerOf(doc, i, k)?.connection.name || type.label;
6395
+ bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't read ${docType.label}s`);
6396
+ }
6114
6397
  });
6115
6398
  // The model is the profile's unless the target's step carries it.
6116
6399
  const bare = conns.findIndex((p , k ) => p && c.profiles && !local(p)
@@ -6317,7 +6600,11 @@ function resolvePipeline(doc ,
6317
6600
  run.profiles[key] = { ...conn, connection: cid };
6318
6601
  }
6319
6602
  const content = contentOf(run) ;
6320
- if (content?.type === "source") content.files = CONTENT_TYPES.source .expand (content, ctx.files);
6603
+ // Saved response grades its one pinned response (#596), whatever else the
6604
+ // Source holds; the server keeps that response's bytes with the run.
6605
+ if (content?.type === "source") {
6606
+ content.files = CONTENT_TYPES.source .expand (content, isObj(content.pin) && content.live !== true ? [content.pin.name] : ctx.files);
6607
+ }
6321
6608
  // Each Library group the evals read, as the version it grades with: the
6322
6609
  // body's fingerprint and, where the lab numbers them, `n` -- a pinned
6323
6610
  // link's pin.
@@ -6584,7 +6871,8 @@ const BUNDLE_DATASET = /^datasets\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/;
6584
6871
  * [doc] as a bundle's text files: `pipeline.yaml`, `profiles.yaml` and
6585
6872
  * `datasets/<slug>.json`. [ctx.connections] and [ctx.connectionProfiles]
6586
6873
  * are Setup's -- keys and all, none of which is written -- or
6587
- * [ctx.profiles], Target profiles to split as the store was; [ctx.grader]
6874
+ * [ctx.profiles], Target profiles to split as the store was, and
6875
+ * [ctx.profileDefault] (Default)'s settings; [ctx.grader]
6588
6876
  * the lab's grader, written into each eval that would have asked it, and
6589
6877
  * [ctx.datasets] each graded dataset by id, as `{ name, body }`. Each
6590
6878
  * connection asked is written resolved, under its answer key with the
@@ -6593,12 +6881,13 @@ const BUNDLE_DATASET = /^datasets\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/;
6593
6881
  * something else.
6594
6882
  */
6595
6883
  function exportBundle(doc , ctx
6596
-
6597
- = {})
6884
+
6885
+ = {})
6598
6886
  {
6599
6887
  const split = (ctx.profiles ?? []).map(splitProfile);
6600
6888
  const profiles = withSlugs([...(ctx.connections ?? []), ...split.map(x => x.connection)] );
6601
- const answers = answersOf(profiles, [...(ctx.connectionProfiles ?? []), ...split.flatMap(x => (x.profile ? [x.profile] : []))]);
6889
+ const answers = answersOf(profiles, [...(ctx.connectionProfiles ?? []), ...split.flatMap(x => (x.profile ? [x.profile] : []))],
6890
+ ctx.profileDefault);
6602
6891
  // What the lab would supply at submit, written down: no lab supplies it later.
6603
6892
  const out = clone(doc) ;
6604
6893
  out.evals = (Array.isArray(out.evals) ? out.evals : [])
@@ -6774,7 +7063,7 @@ function stagesFor(run , i , resolveFilters
6774
7063
  // without a resolver, a Library link reads nothing, a private one its own.
6775
7064
  return { text: targetStepOf(run, i, k) .prompt, kind, withImage: sendsImage(ch),
6776
7065
  verbatim: !!targetEntryOf(run, i, k)?.verbatim, before: beforeFilters(ch, resolveFilters),
6777
- modifiers: stageModifiers(ch, resolveFilters), settings };
7066
+ after: afterFilters(ch, resolveFilters), modifiers: stageModifiers(ch, resolveFilters), settings };
6778
7067
  });
6779
7068
  // Each named by the connection whose key it is sent with.
6780
7069
  const asked = (key , held ) => {
@@ -6913,7 +7202,9 @@ function scenarioEvals(run , i , items
6913
7202
  return evals.map((t, j) => {
6914
7203
  const type = EVAL_TYPES[t.type];
6915
7204
  const whole = isWholeRun(type, t);
6916
- const base = { id: t.id, label: evalLabel({ evals }, j), whole, skipped: skipRest,
7205
+ // An unnamed eval that links a group goes by the group's name, as the
7206
+ // Evals Summary names it, not "Eval 1" (#519).
7207
+ const base = { id: t.id, label: (t.name || "").trim() || t.group?.name || evalLabel({ evals }, j), whole, skipped: skipRest,
6917
7208
  verdict: null , rules: type?.rules?.(t) ?? [], items: cells.map(() => null) ,
6918
7209
  ran: 0, passed: 0, skippedItems: 0 };
6919
7210
  if (skipRest) {
@@ -7053,6 +7344,13 @@ function validateEvals(ev , files
7053
7344
  if (scored.length > 1) say("expects its answer discarded, and states something the answer should hold too");
7054
7345
  continue;
7055
7346
  }
7347
+ if (scored.some(m => m.type === "refused" && !m.not)) {
7348
+ // Nor does a call that was refused: there is no answer. Scored in
7349
+ // points, the other metrics are alternatives rather than more to hold
7350
+ // (#560: refused, or the reply says it can't read the file).
7351
+ if (scored.length > 1 && ev.scoring?.mode !== "weighted") say("expects its call refused, and states something the answer should hold too");
7352
+ continue;
7353
+ }
7056
7354
  const wants = scored.filter(m => !m.not && (m.type === "contains-all" || m.type === "contains-any"));
7057
7355
  const forbids = scored.filter(m => m.not && m.type === "contains");
7058
7356
  for (const m of wants) for (const t of values(m)) if (!matchable(t)) say(`expects ${t}, which its own scorer cannot match`);
@@ -7166,7 +7464,7 @@ export {
7166
7464
  loopReplyError, preparedSize,
7167
7465
  termIn, forbiddenIn, readCase, gradedSetFrom, caseItem, caseMetrics, metricLines, upgradeDatasetBody, takeAsExpected, readGroup, readGroupRun, datasetRules, upgradeResults, emptyTally, addToTally, isHostedUrl,
7168
7466
  SEED, REPLY_TOKENS_BEFORE, ANTHROPIC_MAX_TOKENS, asNumber, pinReplyTokens, mappingsFor,
7169
- CONNECTION_TYPES, HTTP_APIS, httpApiFor, httpRequestOf, httpReplyOf, readFlowReply, WdlError, localAnswer, typeOf, profileType, convertProfile, splitOllama, DECODING_KEYS, apiBase,
7467
+ CONNECTION_TYPES, DOCUMENT_TYPES, documentTypeOf, documentUrl, attachmentOf, HTTP_APIS, httpApiFor, httpRequestOf, httpReplyOf, readFlowReply, WdlError, localAnswer, typeOf, listedConnection, profileType, convertProfile, splitOllama, DECODING_KEYS, apiBase,
7170
7468
  EDGE_448, budgetLabel, IMAGE_FORMATS, encoderQuality,
7171
7469
  tallyPercent, runPipeline, validateEvals, evalsWarnings, evalsJson, readList,
7172
7470
  scoreSingle, singleRules, parseCount, lengthHolds, countHolds, COUNT_OPS, LENGTH_OPS,
@@ -7175,12 +7473,12 @@ export {
7175
7473
  SOURCE_TYPES, DEFAULT_SOURCE_TYPE, sourceTypeOf, WORKFLOW_PLATFORMS, platformOf, DYNAMIC_PROVIDERS, providerOf, WIZARDS, wizardProgress,
7176
7474
  registerKinds, defaultKind, jobDefaults, applyModifiers, keyVar, slugOf, uniqueSlug, isSlug, withSlugs, SLUG_MAX, connectionOf, addressOf, connectionSettings,
7177
7475
  splitProfile, mergeAnswer, answerKey, answerOfKey, answersOf, runAnswer, answerConnectionId, connectionScoped,
7178
- connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, connectionImage, connectionProblems,
7476
+ connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, replyFinish, contentJoined, connectionImage, connectionProblems,
7179
7477
  CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWords, versionProblem,
7180
7478
  contentOf, withContent, replyOf,
7181
7479
  targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
7182
7480
  tokensOf, withTokens, sendsImage, withImage, flowStepOf, requestStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7183
- filterSetsOf, withFilterSets, stageModifiers, beforeFilters,
7481
+ filterSetsOf, withFilterSets, stageModifiers, beforeFilters, afterFilters, gradedText,
7184
7482
  blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
7185
7483
  scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
7186
7484
  evalLabel, evalsOf, evalsDataset, casesRef, groupOf, groupOfMetrics, pipelineWarnings, itemsFound, previewOf, isWholeRun, EVAL_FIELDS,