evals-lab 0.11.0 → 0.13.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 (62) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +4 -4
  3. package/bin/evals-lab.js +9 -1
  4. package/bin/lab-file.js +97 -0
  5. package/bin/run.js +1 -1
  6. package/lab/VERSION +1 -1
  7. package/lab/demo/CREDITS.md +37 -3
  8. package/lab/demo/datasets/demo-1.json +2 -2
  9. package/lab/demo/datasets/demo-2.json +2 -2
  10. package/lab/demo/datasets/sentiment.json +383 -0
  11. package/lab/demo/datasets/weather-alerts.json +61 -0
  12. package/lab/demo/datasets/wordings.json +39 -0
  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/sentiment.json +65 -0
  17. package/lab/demo/pipelines/weather-alerts.json +66 -0
  18. package/lab/demo/pipelines/wordings.json +104 -0
  19. package/lab/demo/sources/Demo 1/source.json +3 -0
  20. package/lab/demo/sources/Demo 2/source.json +3 -0
  21. package/lab/demo/sources/sentiment/bakery-croissant.txt +1 -0
  22. package/lab/demo/sources/sentiment/bus-timetable.txt +1 -0
  23. package/lab/demo/sources/sentiment/cafe-coffee.txt +1 -0
  24. package/lab/demo/sources/sentiment/concert-night.txt +1 -0
  25. package/lab/demo/sources/sentiment/film-sequel.txt +1 -0
  26. package/lab/demo/sources/sentiment/garden-tomatoes.txt +1 -0
  27. package/lab/demo/sources/sentiment/gym-contract.txt +1 -0
  28. package/lab/demo/sources/sentiment/headphones-review.txt +1 -0
  29. package/lab/demo/sources/sentiment/hotel-room.txt +1 -0
  30. package/lab/demo/sources/sentiment/library-hours.txt +1 -0
  31. package/lab/demo/sources/sentiment/meeting-minutes.txt +1 -0
  32. package/lab/demo/sources/sentiment/parcel-lost.txt +1 -0
  33. package/lab/demo/sources/sentiment/phone-update.txt +1 -0
  34. package/lab/demo/sources/sentiment/puppy-home.txt +1 -0
  35. package/lab/demo/sources/sentiment/recipe-note.txt +1 -0
  36. package/lab/demo/sources/sentiment/road-closure.txt +1 -0
  37. package/lab/demo/sources/sentiment/source.json +3 -0
  38. package/lab/demo/sources/sentiment/teacher-thanks.txt +1 -0
  39. package/lab/demo/sources/sentiment/train-delay.txt +1 -0
  40. package/lab/demo/sources/sentiment/volunteer-day.txt +1 -0
  41. package/lab/demo/sources/sentiment/weather-report.txt +1 -0
  42. package/lab/demo/sources/weather-alerts/https-api.weather.gov-alerts-types.txt +116 -0
  43. package/lab/demo/sources/weather-alerts/source.json +8 -0
  44. package/lab/evals-core.mjs +246 -57
  45. package/lab/flows/wdl.mjs +19 -0
  46. package/lab/kinds/list.mjs +5 -2
  47. package/lab/metrics/builtin.mjs +34 -7
  48. package/lab/run-evals.js +30 -11
  49. package/lab/server.py +614 -75
  50. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +5 -0
  51. package/lab/web/dist/assets/main-BSw-jyYD.css +1 -0
  52. package/lab/web/dist/assets/main-CrB8wPZn.js +17 -0
  53. package/lab/web/dist/assets/tokens-CrjDMCbz.js +60 -0
  54. package/lab/web/dist/assets/tokens-DDOONQon.css +1 -0
  55. package/lab/web/dist/gallery.html +3 -3
  56. package/lab/web/dist/index.html +4 -4
  57. package/package.json +1 -1
  58. package/lab/web/dist/assets/gallery-DKy8QK3o.js +0 -5
  59. package/lab/web/dist/assets/main-BGmSu7aS.css +0 -1
  60. package/lab/web/dist/assets/main-DdGON-yl.js +0 -16
  61. package/lab/web/dist/assets/tokens-BmytrwxE.js +0 -60
  62. package/lab/web/dist/assets/tokens-DBN7I0ru.css +0 -1
@@ -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";
@@ -166,13 +166,11 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
166
166
  /** A target step that sends a Power Automate step's request
167
167
  (docs/power-automate.md § The HTTP Request step): the flow's own template
168
168
  -- method, path, query and body, Workflow Definition Language expressions
169
- and all -- with the target's words and fields placed in the body by its
170
- HTTP API, evaluated against each item's record. The profile gives the
171
- address and the key. */
169
+ and all -- with the target's words and fields placed in the body by the
170
+ job's HTTP API (its flow step's), evaluated against each item's record.
171
+ The profile gives the address and the key. */
172
172
 
173
173
 
174
-
175
-
176
174
 
177
175
 
178
176
 
@@ -240,7 +238,7 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
240
238
 
241
239
 
242
240
  /** What a target sends in one job: one of the target step types. */
243
-
241
+
244
242
 
245
243
  /** A target (pipeline-model §16): one column through every job, compared
246
244
  side by side in Results -- what version 9 called a scenario. Its step in
@@ -609,6 +607,9 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
609
607
 
610
608
 
611
609
 
610
+
611
+
612
+
612
613
 
613
614
 
614
615
  /** How much of one text a run keeps -- a reply's body, a grader's prompt or
@@ -899,6 +900,14 @@ export function kept(text , max = KEPT_CHARS) {
899
900
 
900
901
 
901
902
 
903
+
904
+
905
+
906
+
907
+
908
+
909
+
910
+
902
911
 
903
912
 
904
913
 
@@ -1692,9 +1701,17 @@ function datasetRules(body ) {
1692
1701
 
1693
1702
 
1694
1703
 
1704
+
1705
+
1706
+
1707
+
1695
1708
 
1696
1709
 
1697
1710
 
1711
+
1712
+
1713
+
1714
+
1698
1715
 
1699
1716
 
1700
1717
 
@@ -1719,6 +1736,10 @@ function datasetRules(body ) {
1719
1736
 
1720
1737
 
1721
1738
 
1739
+
1740
+
1741
+
1742
+
1722
1743
 
1723
1744
 
1724
1745
 
@@ -1991,6 +2012,35 @@ function asNumber(v ) {
1991
2012
  // a tuned local engine is a llama.cpp profile with its settings.
1992
2013
  const CONNECTION_TYPES = Object.create(null);
1993
2014
 
2015
+ // ---- documents (#558) ------------------------------------------------------
2016
+ // A file a model's API reads whole, sent unprocessed: the lab does not parse,
2017
+ // render or extract it. It travels where an image does -- the item's
2018
+ // attachment, a data URL -- carrying its name, which some APIs want beside
2019
+ // the bytes. Only a connection type marked `documents` is handed one.
2020
+ const DOCUMENT_TYPES = {
2021
+ ".pdf": { media: "application/pdf", label: "PDF" },
2022
+ };
2023
+
2024
+ /** The DOCUMENT_TYPES entry a file's name is, or null. */
2025
+ function documentTypeOf(name ) {
2026
+ const dot = String(name).lastIndexOf(".");
2027
+ return dot < 0 ? null : DOCUMENT_TYPES[String(name).slice(dot).toLowerCase()] ?? null;
2028
+ }
2029
+
2030
+ /** A document's bytes, base64, as the attachment a stage sends. */
2031
+ function documentUrl(name , media , base64 ) {
2032
+ return `data:${media};name=${encodeURIComponent(name)};base64,${base64}`;
2033
+ }
2034
+
2035
+ /** An attachment's media type, bytes and name (null for an image, which
2036
+ carries none), or null for anything that is not a base64 data URL. */
2037
+ function attachmentOf(dataUrl ) {
2038
+ const m = /^data:([^;,]+)((?:;[^;,=]+=[^;,]*)*);base64,(.*)$/s.exec(dataUrl);
2039
+ if (!m) return null;
2040
+ const name = /;name=([^;,]*)/.exec(m[2] )?.[1];
2041
+ return { media: m[1] , data: m[3] , name: name == null ? null : decodeURIComponent(name) };
2042
+ }
2043
+
1994
2044
  /** The key [key] as the header most endpoints expect, when there is one. */
1995
2045
  const bearer = (key ) => (key ? { Authorization: "Bearer " + key } : {});
1996
2046
 
@@ -2103,6 +2153,13 @@ CONNECTION_TYPES.recorded = {
2103
2153
  request: sendsNothing, parseReply: sendsNothing, listModels: sendsNothing, parseModels: () => [],
2104
2154
  };
2105
2155
 
2156
+ /** Whether Setup and the connection pickers list a stored connection. A local
2157
+ type's (Echo, Recorded) is a target step's stand-in now (`localSteps`), so
2158
+ a row of one is kept only for the pipelines and runs that name it. */
2159
+ function listedConnection(conn ) {
2160
+ return !CONNECTION_TYPES[typeOf(conn )]?.local;
2161
+ }
2162
+
2106
2163
  /** A local type's answer to one stage, as a model's would come back. An item
2107
2164
  with no text of its own (Prompt only) is answered from the prompt, and
2108
2165
  that reply is read against nothing -- the prompt is the reply, not an
@@ -2118,9 +2175,19 @@ function localAnswer(conn , text , sent , record
2118
2175
  const chatSchema = (body , schema ) =>
2119
2176
  ({ ...body, response_format: { type: "json_schema", json_schema: { name: "reply", strict: true, schema } } });
2120
2177
 
2178
+ /** An item's attachment as a chat completions content part: an image as
2179
+ image_url, a document as a file part, named, its bytes a data URL. */
2180
+ function chatAttachment(dataUrl ) {
2181
+ const a = attachmentOf(dataUrl);
2182
+ if (!a || a.media.startsWith("image/")) return { type: "image_url", image_url: { url: dataUrl } };
2183
+ return { type: "file", file: { filename: a.name ?? "document", file_data: `data:${a.media};base64,${a.data}` } };
2184
+ }
2185
+
2121
2186
  CONNECTION_TYPES["openai-compatible"] = {
2122
2187
  id: "openai-compatible", label: "OpenAI-compatible",
2123
2188
  description: "OpenAI, OpenRouter, vLLM, LM Studio: any chat completions endpoint.",
2189
+ // A hosted provider wants a key; a server of your own may not.
2190
+ keyOptional: (conn) => !isHostedUrl(String(conn.url ?? "")),
2124
2191
  settings: [
2125
2192
  // A reasoning model refuses a temperature.
2126
2193
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "",
@@ -2143,11 +2210,14 @@ CONNECTION_TYPES["openai-compatible"] = {
2143
2210
  ...(predict == null ? {} : hosted ? { max_completion_tokens: predict } : { max_tokens: predict }),
2144
2211
  messages: [{ role: "user", content: [
2145
2212
  { type: "text", text: prompt },
2146
- ...(dataUrl ? [{ type: "image_url", image_url: { url: dataUrl } }] : []),
2213
+ ...(dataUrl ? [chatAttachment(dataUrl)] : []),
2147
2214
  ] }],
2148
2215
  },
2149
2216
  };
2150
2217
  },
2218
+ // OpenAI's file part; a compatible server that has none refuses the call,
2219
+ // and the refusal is that item's error.
2220
+ documents: true,
2151
2221
  structured: chatSchema,
2152
2222
  parseReply: chatReply,
2153
2223
  listModels, parseModels: idsFrom,
@@ -2170,16 +2240,17 @@ CONNECTION_TYPES.anthropic = {
2170
2240
  // Asked words (a Prompt step, a grader) or sent a flow's whole request (an
2171
2241
  // HTTP Request step): one profile and one key do both.
2172
2242
  answers: ["prompt", "request"],
2243
+ speaks: () => "anthropic",
2173
2244
  // The Messages API: the x-api-key and anthropic-version headers, base64
2174
- // image content blocks, and /v1/models for the list.
2245
+ // image and document content blocks, and /v1/models for the list.
2246
+ documents: true,
2175
2247
  request(conn, prompt, dataUrl, base, key) {
2176
2248
  const temp = asNumber(conn.temperature);
2177
2249
  const predict = asNumber(conn.maxTokens) ?? ANTHROPIC_MAX_TOKENS;
2178
2250
  const content = [];
2179
- if (dataUrl) {
2180
- const m = /^data:(image\/[^;]+);base64,(.*)$/s.exec(dataUrl);
2181
- if (m) content.push({ type: "image", source: { type: "base64", media_type: m[1], data: m[2] } });
2182
- }
2251
+ const a = dataUrl ? attachmentOf(dataUrl) : null;
2252
+ if (a?.media.startsWith("image/")) content.push({ type: "image", source: { type: "base64", media_type: a.media, data: a.data } });
2253
+ else if (a) content.push({ type: "document", source: { type: "base64", media_type: a.media, data: a.data } });
2183
2254
  content.push({ type: "text", text: prompt });
2184
2255
  return {
2185
2256
  url: base + "/messages",
@@ -2224,6 +2295,7 @@ const SPEED_IMAGE = "before", SPEED_CACHE = "off", SPEED_THINKING = "off";
2224
2295
  CONNECTION_TYPES["llama.cpp"] = {
2225
2296
  id: "llama.cpp", label: "llama.cpp",
2226
2297
  description: "A llama-server you run, with its sampler settings.",
2298
+ keyOptional: () => true,
2227
2299
  settings: [
2228
2300
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "" },
2229
2301
  { key: "seed", label: "Seed", control: "text", default: "", hint: "" },
@@ -2501,8 +2573,18 @@ CONNECTION_TYPES.http = {
2501
2573
  { key: "headers", label: "Headers", control: "text", default: "", hint: "", scope: "connection" },
2502
2574
  // What Test connection asks of it: a GET, keyed as a run is.
2503
2575
  { key: "testPath", label: "Test path", control: "text", default: "", hint: "", scope: "connection" },
2576
+ // Read off the address (httpApiFor); set by hand for one the lab cannot
2577
+ // tell, such as a gateway in front of a provider.
2578
+ { key: "requestFormat", label: "Request format", control: "select", default: "", hint: "", scope: "connection",
2579
+ choices: [{ value: "", label: "From address" },
2580
+ ...Object.entries(HTTP_APIS).map(([value, a]) => ({ value, label: a.label }))] },
2504
2581
  ],
2505
2582
  answers: ["request"],
2583
+ keyOptional: (conn) => conn.auth === "none",
2584
+ speaks(conn) {
2585
+ const api = String(conn.requestFormat || "") || httpApiFor(originBase(conn.url));
2586
+ return HTTP_APIS[api] && api !== "raw" ? api : null;
2587
+ },
2506
2588
  lists: false,
2507
2589
  baseOf: originBase,
2508
2590
  send(conn, req, base, key) {
@@ -2785,7 +2867,7 @@ function readCase(kase , res , plain = fa
2785
2867
  }
2786
2868
  return r ? [r ] : [];
2787
2869
  });
2788
- const s = scoreOf(metrics) ?? { pass: true, score: null, metrics };
2870
+ const s = erroredScore(scoreOf(metrics), res.error) ?? { pass: !res.error, score: null, metrics };
2789
2871
  const counted = metrics.filter(r => r.weight !== 0);
2790
2872
  return { ...s, metrics, found: s.found ?? [], missed: s.missed ?? [], invented: s.invented ?? [],
2791
2873
  discarded: !!res.error, count: res.error ? 0 : (res.terms || []).length,
@@ -3183,6 +3265,7 @@ async function runPipeline(stages , dataUrl , cal
3183
3265
  const pre = applyFilterSet(st.before || [], { body: res.raw ?? "", items: [], instruction: against }, "text");
3184
3266
  const by = (filter , stage , rejected ) => ({ filter, stage, job: i + 1, rejected });
3185
3267
  if (pre.reject != null) return result({ error: named(`discarded: ${pre.reject}`), filteredBy: by(pre.reject, "text", true) });
3268
+ if (pre.rewrote) transcript[transcript.length - 1] .filtered = { got: pre.body, by: pre.rewrote };
3186
3269
  const read = reader.read(pre.body, { echo: against, parsing: opts.parsing, settings: st.settings });
3187
3270
  if (read.error) return result({ error: named(read.error) });
3188
3271
  const mod = applyModifiers(mods[i], kind, read.value, { instruction: against, body: read.body ?? res.raw ?? "" });
@@ -3297,7 +3380,7 @@ function applyModifiers (list , kind ,
3297
3380
  // 16: a target names an AI Connection and, optionally, a Connection Profile,
3298
3381
  // where it named one Target profile; a run's table is keyed by the pair
3299
3382
  // (answerKey) -- the Target profile split in the store as it opens (#445).
3300
- const PIPELINE_VERSION = 16 ;
3383
+ const PIPELINE_VERSION = 17 ;
3301
3384
 
3302
3385
  // Plain objects, so an entry is added by assignment and a reader never needs
3303
3386
  // to know which registered it.
@@ -3719,16 +3802,17 @@ function onlyFields(obj , at , allowed , bad
3719
3802
 
3720
3803
  // A Source is referenced, with the two numbers that decide how many items a
3721
3804
  // run over it is: the first N files, and how many times over.
3722
- // A folder of files: images sent as images, text files as {text}. The
3723
- // extensions are server.py's FILE_TYPES, which the server holds uploads to.
3805
+ // A folder of files: images sent as images, documents whole (DOCUMENT_TYPES),
3806
+ // text files as {text}. The extensions are server.py's FILE_TYPES, which the
3807
+ // server holds uploads to.
3724
3808
  const FILE_LIBRARY_TEXT = [".txt", ".md", ".csv"] ;
3725
3809
  SOURCE_TYPES.files = {
3726
3810
  label: "File Library",
3727
3811
  icon: "folder",
3728
- description: "A folder of images and text files you upload.",
3812
+ description: "A folder of images, PDFs and text files you upload.",
3729
3813
  group: "Files",
3730
3814
  noun: "file",
3731
- uploads: [".jpg", ".jpeg", ".png", ".heic", ".heif", ".dng", ".tif", ".tiff", ...FILE_LIBRARY_TEXT],
3815
+ uploads: [".jpg", ".jpeg", ".png", ".heic", ".heif", ".dng", ".tif", ".tiff", ...Object.keys(DOCUMENT_TYPES), ...FILE_LIBRARY_TEXT],
3732
3816
  sendsImage: true,
3733
3817
  hasActions: false,
3734
3818
  itemText: name => FILE_LIBRARY_TEXT.some(ext => name.toLowerCase().endsWith(ext)),
@@ -3745,7 +3829,9 @@ SOURCE_TYPES.workflow = {
3745
3829
  icon: "workflow",
3746
3830
  description: "A cloud flow, with records of its steps' calls from its run history.",
3747
3831
  group: "Workflows",
3748
- noun: "record",
3832
+ // Each item reads as a call, as the Source's Calls group names them, though
3833
+ // the file holding one is a record.
3834
+ noun: "call",
3749
3835
  uploads: [".json"],
3750
3836
  sendsImage: false,
3751
3837
  hasActions: true,
@@ -3834,7 +3920,7 @@ const API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
3834
3920
  DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3835
3921
  id: "api", // provider: its id, the row's config.provider
3836
3922
  label: "API call",
3837
- icon: "square-code",
3923
+ icon: "arrow-left-right",
3838
3924
  noun: "call",
3839
3925
  tier: "pull",
3840
3926
  config: ["method", "url", "reqHeaders", "body", "connection"],
@@ -4104,7 +4190,7 @@ LEGACY_TESTS.single = {
4104
4190
  const raws = [], terms = [];
4105
4191
  for (const r of ress) {
4106
4192
  if (!r) continue;
4107
- raws.push(r.transcript?.[r.transcript.length - 1]?.got ?? r.raw ?? "");
4193
+ raws.push(gradedText(r));
4108
4194
  terms.push(...(r.terms || []));
4109
4195
  }
4110
4196
  const s = scoreSingle(t, terms, raws, !(kind != null && OUTPUT_KINDS[kind]?.terms));
@@ -4169,11 +4255,32 @@ function metricsProblems(list , at , bad ) {
4169
4255
  /** Whether any of [list] asks a grader. */
4170
4256
  const gradedIn = (list ) => Array.isArray(list) && list.some((m ) => !!METRICS[m?.type]?.graded);
4171
4257
 
4258
+ /** Whether [error] is a reply the job's rules threw away, as the Discarded
4259
+ metric reads it: a result that came back, where any other error is a
4260
+ request that never did. */
4261
+ const thrownAway = (error ) => !!error && error.startsWith("discarded: ");
4262
+
4263
+ /** [s] as an errored result's score: whatever its metrics read, a result
4264
+ with no reply fails, and only a discarded reply is left to them (#544) --
4265
+ or a refused call, to a case that scores whether it was (#559). */
4266
+ function erroredScore (s , error ) {
4267
+ const asksRefusal = !!s?.metrics?.some(r => r.weight !== 0 && METRICS[r.type]?.readsRefusal);
4268
+ return s && error != null && !thrownAway(error) && !asksRefusal ? { ...s, pass: false } : s;
4269
+ }
4270
+
4271
+ /** The reply a metric grades and Results shows (#546): the last stage's
4272
+ reply after its Pre-format filters, or the raw reply. */
4273
+ function gradedText(res ) {
4274
+ const last = res?.transcript?.at(-1);
4275
+ return last?.filtered?.got ?? last?.got ?? res?.raw ?? "";
4276
+ }
4277
+
4172
4278
  /** What a metric reads of one result. */
4173
4279
  function metricInput(res , kase , more ) {
4174
4280
  const last = res.transcript?.at(-1);
4175
- const text = last?.got ?? res.raw ?? "";
4176
- return { text, said: last?.said ?? null, terms: res.terms ?? [], replies: [text], plain: !!more.plain,
4281
+ const text = gradedText(res);
4282
+ // `of: "said"` reads the reply as it came back, before any filter.
4283
+ return { text, said: last?.said ?? (last?.filtered ? last.got ?? null : null), terms: res.terms ?? [], replies: [text], plain: !!more.plain,
4177
4284
  asked: last?.sent ?? null,
4178
4285
  // A case that has been given its own expected reply ("Take B as
4179
4286
  // expected") is compared with that; otherwise the Source's recorded
@@ -4189,7 +4296,7 @@ function runInput(ress , plain )
4189
4296
  let ms = 0;
4190
4297
  for (const r of ress) {
4191
4298
  if (!r) continue;
4192
- replies.push(r.transcript?.[r.transcript.length - 1]?.got ?? r.raw ?? "");
4299
+ replies.push(gradedText(r));
4193
4300
  terms.push(...(r.terms || []));
4194
4301
  ms += r.ms ?? 0;
4195
4302
  }
@@ -4205,6 +4312,19 @@ function readMetric(m , input , ctx )
4205
4312
  const label = (isStr(m.metric) && m.metric.trim()) || entry?.label || m.type;
4206
4313
  if (!entry) return { type: m.type, label, weight, pass: false, score: 0, reason: `"${m.type}" is not a metric this lab has` };
4207
4314
  const read = m.of === "said" ? { ...input, text: input.said ?? input.text } : input;
4315
+ // An errored result has no reply to read (#544): a metric that reads one
4316
+ // fails on the error, and a grader is never asked about the empty reply.
4317
+ if (input.error != null && !(entry.readsResult && (thrownAway(input.error) || entry.readsRefusal))) {
4318
+ const fail = { type: m.type, label, weight, pass: false, score: 0, reason: input.error };
4319
+ if (entry.graded) return fail;
4320
+ // What it would have missed still counts toward a run's totals.
4321
+ let o = null;
4322
+ try { o = entry.score(read, m, {}); } catch { o = null; }
4323
+ if (!o) return null;
4324
+ if (typeof (o ).then === "function") return fail;
4325
+ const { found, missed, invented } = o ;
4326
+ return { ...fail, ...(found ? { found } : {}), ...(missed ? { missed } : {}), ...(invented ? { invented } : {}) };
4327
+ }
4208
4328
  const done = (o ) => {
4209
4329
  if (!o) return null;
4210
4330
  const turned = m.not ? { ...o, pass: !o.pass, score: entry.counts ? -o.score : 1 - o.score, reason: `not: ${o.reason}` } : o;
@@ -4237,6 +4357,18 @@ function readMetric(m , input , ctx )
4237
4357
  } catch (e) { return timed(failed(e)); }
4238
4358
  }
4239
4359
 
4360
+ /** What stops [grader] being asked, where the lookups can say: a grader is
4361
+ asked words, and needs a model to ask. */
4362
+ function graderProblems(grader , ctx , bad ) {
4363
+ const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4364
+ if (grader && ctx.profiles && !p) bad.push(`Grader ${grader.name || grader.id} not found`);
4365
+ else if (p) {
4366
+ const type = CONNECTION_TYPES[typeOf(p )];
4367
+ if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4368
+ else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4369
+ }
4370
+ }
4371
+
4240
4372
  /** An eval's score from its metrics' readings, the way [mode] says: all must
4241
4373
  pass, or weighted points against [threshold]. What a case metric found,
4242
4374
  missed and invented is carried up, for a run's totals. Null for none. */
@@ -4265,7 +4397,7 @@ async function readMetrics(list , input , ctx
4265
4397
  const r = await readMetric(m, input, ctx);
4266
4398
  if (r) metrics.push(r);
4267
4399
  }
4268
- return scoreOf(metrics, mode, threshold);
4400
+ return erroredScore(scoreOf(metrics, mode, threshold), input.error);
4269
4401
  }
4270
4402
 
4271
4403
  /** [list] read over a whole run, synchronously: each metric over the run's
@@ -4319,14 +4451,7 @@ LEGACY_TESTS.metrics = {
4319
4451
  // The eval's own grader, or the lab's.
4320
4452
  const grader = isRef(t.grader) ? t.grader : isRef(ctx.grader) ? ctx.grader : null;
4321
4453
  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");
4322
- // A grader is asked words, and needs a model to ask.
4323
- const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4324
- if (grader && ctx.profiles && !p) bad.push(`Connection ${grader.name || grader.id} not found`);
4325
- else if (p) {
4326
- const type = CONNECTION_TYPES[typeOf(p )];
4327
- if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4328
- else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4329
- }
4454
+ graderProblems(grader, ctx, bad);
4330
4455
  },
4331
4456
  rules: t => (Array.isArray(t.metrics) ? t.metrics : []).map((m , x ) => ({
4332
4457
  key: `m${x}`, label: (isStr(m.metric) && m.metric) || METRICS[m.type]?.label || m.type,
@@ -4438,6 +4563,11 @@ EVAL_TYPES.group = {
4438
4563
  if (held && t.pin != null && typeof held.versions === "number" && t.pin > held.versions) {
4439
4564
  bad.push(`${g.name || g.id} has no version ${t.pin}`);
4440
4565
  }
4566
+ // The grader the group asks: the run's copy of the link carries it, a
4567
+ // pipeline's reads it from the group as the lab lists it. One that is
4568
+ // gone would fail every item on a metric that cannot run (#545).
4569
+ const grader = isRef(t.grader) ? t.grader : isRef(held?.grader) ? held .grader : null;
4570
+ graderProblems(grader, ctx, bad);
4441
4571
  return;
4442
4572
  }
4443
4573
  if (!isObj(own)) return void bad.push("an eval links a Library group, or holds its own");
@@ -4454,14 +4584,7 @@ EVAL_TYPES.group = {
4454
4584
  // The group's own grader, or the lab's.
4455
4585
  const grader = isRef(own.grader) ? own.grader : isRef(ctx.grader) ? ctx.grader : null;
4456
4586
  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");
4457
- // A grader is asked words, and needs a model to ask.
4458
- const p = grader && ctx.profiles ? ctx.profiles(grader.id) : null;
4459
- if (grader && ctx.profiles && !p) bad.push(`Connection ${grader.name || grader.id} not found`);
4460
- else if (p) {
4461
- const type = CONNECTION_TYPES[typeOf(p )];
4462
- if (type && !(type.answers ?? ["prompt"]).includes("prompt")) bad.push(`the grader has to be asked words, and ${type.label} is not`);
4463
- else if (!type?.local && !String((p ).model || "").trim()) bad.push("no model on the grader's connection");
4464
- }
4587
+ graderProblems(grader, ctx, bad);
4465
4588
  },
4466
4589
  rules: t => ownList(t).map((m , x ) => ({
4467
4590
  key: `m${x}`, label: (isStr(m.metric) && m.metric) || METRICS[m.type]?.label || m.type,
@@ -4818,16 +4941,18 @@ STEP_TYPES.httpRequest = {
4818
4941
  label: "HTTP Request", slot: "target", asks: "request", verbatim: true, modelFrom: "step",
4819
4942
  description: "Sends a Power Automate step's own request, rebuilt from each record, with each target's words in it.",
4820
4943
  firstJobOnly: "an HTTP Request is sent from its item's record, so it is job 1's",
4821
- // A new target starts from the flow's own words and fields.
4822
- newStep: (step) => ({ type: "httpRequest", api: step.api, method: step.method, path: step.path,
4944
+ // A new target starts from the flow's own words and fields, read by the
4945
+ // job's HTTP API ([step] carries it: requestStepOf).
4946
+ newStep: (step) => ({ type: "httpRequest", method: step.method, path: step.path,
4823
4947
  query: clone(step.query), body: clone(step.body),
4824
4948
  ...(HTTP_APIS[step.api] ?? HTTP_APIS.raw ).cellOf(step.body) }),
4825
4949
  in: "item", out: "text",
4826
4950
  apply: "runPipeline",
4827
- fields: ["type", "api", "method", "path", "query", "body", "prompt", "connection", "profile", "from", "system", "model", "settings", "prefill"],
4951
+ fields: ["type", "method", "path", "query", "body", "prompt", "connection", "profile", "from", "system", "model", "settings", "prefill"],
4952
+ // [step] carries its job's HTTP API, where the job has a flow step to say
4953
+ // it (validatePipeline); the flow step's own check names an unknown one.
4828
4954
  validate(step, _ctx, bad, at){
4829
4955
  const api = HTTP_APIS[step.api];
4830
- if (!api) bad.push(`${at}: "${step.api}" is not an HTTP API this lab has`);
4831
4956
  if (!HTTP_METHODS.includes(step.method)) bad.push(`${at}: the method is one of ${HTTP_METHODS.join(", ")}`);
4832
4957
  if (!isStr(step.path) || !/^[/@]/.test(step.path)) bad.push(`${at}: the path starts with /`);
4833
4958
  if (!isObj(step.query) || !Object.values(step.query).every(isStr)) bad.push(`${at}: the query is names to text`);
@@ -4850,8 +4975,12 @@ STEP_TYPES.httpRequest = {
4850
4975
  }
4851
4976
  }
4852
4977
  if (isStr(step.prompt)) {
4853
- try { api.place(step.body, step ); }
4854
- catch (e) { bad.push(`${at}: ${(e ).message}`); }
4978
+ // Built here as each call builds it, so an expression none of them
4979
+ // could read is refused before the run rather than failing every call.
4980
+ try {
4981
+ const why = templateProblem([step.path, step.query, api.place(step.body, step )]);
4982
+ if (why) bad.push(`${at}: the request could not be built -- ${why}`);
4983
+ } catch (e) { bad.push(`${at}: ${(e ).message}`); }
4855
4984
  }
4856
4985
  },
4857
4986
  };
@@ -4876,7 +5005,6 @@ STEP_TYPES.echo = {
4876
5005
  // the flow reads one. No profile, no prompt -- the record is the reply.
4877
5006
  STEP_TYPES.recorded = {
4878
5007
  label: "Recorded", slot: "target", asks: "nothing", local: true, replaces: "recorded",
4879
- description: "Pre-recorded from previous workflow runs, replays only.",
4880
5008
  firstJobOnly: "a call's recorded reply is job 1's",
4881
5009
  in: "item", out: "text",
4882
5010
  apply: "runPipeline",
@@ -5104,7 +5232,7 @@ function requestStepOf(doc
5104
5232
 
5105
5233
  /** What httpRequestOf builds a request from: a target's template, and the
5106
5234
  loop its flow step sits in. */
5107
-
5235
+
5108
5236
  /** What httpReplyOf reads a reply with: the flow step, its API, its reading. */
5109
5237
 
5110
5238
 
@@ -5706,10 +5834,14 @@ function upgradePipeline (doc , ctx = {}) {
5706
5834
  }
5707
5835
  return localSteps(ownBodiesNow(out), ctx) ;
5708
5836
  }
5709
- if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].includes(doc.version )) return doc;
5837
+ if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].includes(doc.version )) return doc;
5838
+ // Every version reads as 16 first, then as 17: its requests read by their
5839
+ // job's HTTP API (requestsOnJobApi).
5840
+ const seventeen = (d ) => localSteps(ownBodiesNow(requestsOnJobApi(d)), ctx) ;
5841
+ if (doc.version === 16) return seventeen(cutRefs(clone(doc) ));
5710
5842
  // Every version reads as 15 first, then as 16: its Target profiles split
5711
5843
  // into AI Connections and Connection Profiles (answersFromProfiles).
5712
- const sixteen = (fifteen ) => localSteps(ownBodiesNow(answersFromProfiles(fifteen, ctx)), ctx) ;
5844
+ const sixteen = (fifteen ) => seventeen(answersFromProfiles(fifteen, ctx));
5713
5845
  if (doc.version === 15) return sixteen(cutRefs(clone(doc) ));
5714
5846
  // Versions 13 and 14 read as 15 by migrating a job's cleaning modifier steps
5715
5847
  // and its List `preamble` setting into Pre-/Post-format filter sets
@@ -5854,6 +5986,37 @@ function localSteps(doc , ctx ) {
5854
5986
  return next;
5855
5987
  }
5856
5988
 
5989
+ /**
5990
+ * Version 16 to 17 (#477): a request step has no HTTP API of its own; its
5991
+ * job's flow step says it. A step that named another -- Any endpoint over an
5992
+ * Anthropic flow, say -- keeps its body as it would have sent it, read again
5993
+ * by the job's (its words and fields as that API holds them). A request in a
5994
+ * job with no flow step, which could never run, is a Prompt keeping its words.
5995
+ */
5996
+ function requestsOnJobApi(next ) {
5997
+ next.version = PIPELINE_VERSION;
5998
+ const jobs = Array.isArray(next.jobs) ? next.jobs : [];
5999
+ const step = (st , k ) => {
6000
+ if (!isObj(st) || st.type !== "httpRequest") return st;
6001
+ const { api: own, ...rest } = st;
6002
+ const flow = isObj(jobs[k]) ? flowStepOf(jobs[k] ) : null;
6003
+ if (!flow) {
6004
+ return { type: "prompt", prompt: rest.prompt,
6005
+ ...Object.fromEntries(["connection", "profile", "from"].filter(f => rest[f] != null).map(f => [f, rest[f]])) };
6006
+ }
6007
+ const was = HTTP_APIS[own ], now = HTTP_APIS[flow.api];
6008
+ if (!was || !now || own === flow.api) return rest;
6009
+ let body = rest.body;
6010
+ try { body = was.place(rest.body, rest ); } catch { /* words that never placed: the template stands */ }
6011
+ const { system: _s, model: _m, settings: _t, prefill: _p, ...kept } = rest;
6012
+ return { ...kept, body, ...now.cellOf(body) };
6013
+ };
6014
+ if (Array.isArray(next.targets)) {
6015
+ next.targets = next.targets.map((t ) => (isObj(t) && Array.isArray(t.steps) ? { ...t, steps: t.steps.map(step) } : t));
6016
+ }
6017
+ return next;
6018
+ }
6019
+
5857
6020
  /** [next] as version 9 held it, from any version before 10. */
5858
6021
  function nineOf(next , ctx ) {
5859
6022
  if (next.version === 9) { profileRefs(next); return next; }
@@ -5975,7 +6138,9 @@ function validatePipeline(input , ctx = {}) {
5975
6138
  if (entry.local && (st.profile != null || st.connection != null)) return void bad.push(`${where}: ${entry.label} asks no connection`);
5976
6139
  if (k > 0 && entry.firstJobOnly) return void bad.push(`${where}: ${entry.firstJobOnly}`);
5977
6140
  onlyFields(st, where, entry.fields ?? [], bad);
5978
- entry.validate(st, c, bad, where);
6141
+ // A request is read and placed by its job's HTTP API.
6142
+ const flow = entry.asks === "request" ? flowStepOf(doc.jobs[k]) : null;
6143
+ entry.validate(flow ? { ...st, api: flow.api } : st, c, bad, where);
5979
6144
  answerRefsProblems(st, where, bad);
5980
6145
  if (st.from != null && !isRef(st.from)) bad.push(`${where} has to name the prompt it was picked from as { id, name }`);
5981
6146
  // A request is a flow step's, rebuilt from its records, and its
@@ -6031,6 +6196,10 @@ function validatePipeline(input , ctx = {}) {
6031
6196
  ? (c.sources?.(content.ref?.id)?.files ?? content.files ?? null) : null;
6032
6197
  const nonText = (contentFiles || []).map(f => String((f )?.name ?? f))
6033
6198
  .find(n => !/\.(?:txt|md|csv)$/i.test(n));
6199
+ // A document goes whole to the API, so only a type that reads one is
6200
+ // handed it -- never sent as an image or as text it is not.
6201
+ const docType = (contentFiles || []).map(f => documentTypeOf(String((f )?.name ?? f)))
6202
+ .find(Boolean) ?? null;
6034
6203
  targets.forEach((_, i) => {
6035
6204
  const conns = doc.jobs.map((_ , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k)) : lookup(targetAnswerOf(doc, i, k) )));
6036
6205
  conns.forEach((p , k ) => {
@@ -6050,6 +6219,19 @@ function validatePipeline(input , ctx = {}) {
6050
6219
  const type = CONNECTION_TYPES[typeOf(p )];
6051
6220
  if (type && !(type.answers ?? ["prompt"]).includes(step.asks ?? "prompt")) {
6052
6221
  bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${step.label ?? "this step"} needs a profile that answers it, and ${type.label} does not`);
6222
+ return;
6223
+ }
6224
+ // A request goes as the flow wrote it, so its connection has to speak
6225
+ // the job's HTTP API; one the lab cannot tell (Any endpoint) passes.
6226
+ const api = step.asks === "request" ? flowStepOf(doc.jobs[k])?.api : undefined;
6227
+ const speaks = api && api !== "raw" && type?.speaks ? type.speaks(p ) : null;
6228
+ if (api && speaks && speaks !== api) {
6229
+ const name = targetAnswerOf(doc, i, k)?.connection.name || type .label;
6230
+ bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't send an ${HTTP_APIS[api]?.label ?? api} request`);
6231
+ }
6232
+ if (docType && type && !type.documents && sendsImage(doc.jobs[k])) {
6233
+ const name = targetAnswerOf(doc, i, k)?.connection.name || type.label;
6234
+ bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't read ${docType.label}s`);
6053
6235
  }
6054
6236
  });
6055
6237
  // The model is the profile's unless the target's step carries it.
@@ -6853,7 +7035,9 @@ function scenarioEvals(run , i , items
6853
7035
  return evals.map((t, j) => {
6854
7036
  const type = EVAL_TYPES[t.type];
6855
7037
  const whole = isWholeRun(type, t);
6856
- const base = { id: t.id, label: evalLabel({ evals }, j), whole, skipped: skipRest,
7038
+ // An unnamed eval that links a group goes by the group's name, as the
7039
+ // Evals Summary names it, not "Eval 1" (#519).
7040
+ const base = { id: t.id, label: (t.name || "").trim() || t.group?.name || evalLabel({ evals }, j), whole, skipped: skipRest,
6857
7041
  verdict: null , rules: type?.rules?.(t) ?? [], items: cells.map(() => null) ,
6858
7042
  ran: 0, passed: 0, skippedItems: 0 };
6859
7043
  if (skipRest) {
@@ -6993,6 +7177,11 @@ function validateEvals(ev , files
6993
7177
  if (scored.length > 1) say("expects its answer discarded, and states something the answer should hold too");
6994
7178
  continue;
6995
7179
  }
7180
+ if (scored.some(m => m.type === "refused" && !m.not)) {
7181
+ // Nor does a call that was refused: there is no answer.
7182
+ if (scored.length > 1) say("expects its call refused, and states something the answer should hold too");
7183
+ continue;
7184
+ }
6996
7185
  const wants = scored.filter(m => !m.not && (m.type === "contains-all" || m.type === "contains-any"));
6997
7186
  const forbids = scored.filter(m => m.not && m.type === "contains");
6998
7187
  for (const m of wants) for (const t of values(m)) if (!matchable(t)) say(`expects ${t}, which its own scorer cannot match`);
@@ -7106,7 +7295,7 @@ export {
7106
7295
  loopReplyError, preparedSize,
7107
7296
  termIn, forbiddenIn, readCase, gradedSetFrom, caseItem, caseMetrics, metricLines, upgradeDatasetBody, takeAsExpected, readGroup, readGroupRun, datasetRules, upgradeResults, emptyTally, addToTally, isHostedUrl,
7108
7297
  SEED, REPLY_TOKENS_BEFORE, ANTHROPIC_MAX_TOKENS, asNumber, pinReplyTokens, mappingsFor,
7109
- CONNECTION_TYPES, HTTP_APIS, httpApiFor, httpRequestOf, httpReplyOf, readFlowReply, WdlError, localAnswer, typeOf, profileType, convertProfile, splitOllama, DECODING_KEYS, apiBase,
7298
+ CONNECTION_TYPES, DOCUMENT_TYPES, documentTypeOf, documentUrl, attachmentOf, HTTP_APIS, httpApiFor, httpRequestOf, httpReplyOf, readFlowReply, WdlError, localAnswer, typeOf, listedConnection, profileType, convertProfile, splitOllama, DECODING_KEYS, apiBase,
7110
7299
  EDGE_448, budgetLabel, IMAGE_FORMATS, encoderQuality,
7111
7300
  tallyPercent, runPipeline, validateEvals, evalsWarnings, evalsJson, readList,
7112
7301
  scoreSingle, singleRules, parseCount, lengthHolds, countHolds, COUNT_OPS, LENGTH_OPS,
@@ -7119,8 +7308,8 @@ export {
7119
7308
  CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWords, versionProblem,
7120
7309
  contentOf, withContent, replyOf,
7121
7310
  targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
7122
- tokensOf, withTokens, sendsImage, withImage, flowStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7123
- filterSetsOf, withFilterSets, stageModifiers, beforeFilters,
7311
+ tokensOf, withTokens, sendsImage, withImage, flowStepOf, requestStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7312
+ filterSetsOf, withFilterSets, stageModifiers, beforeFilters, gradedText,
7124
7313
  blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
7125
7314
  scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
7126
7315
  evalLabel, evalsOf, evalsDataset, casesRef, groupOf, groupOfMetrics, pipelineWarnings, itemsFound, previewOf, isWholeRun, EVAL_FIELDS,
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 ) {