evals-lab 0.12.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ Newest first. Read a release's **Upgrade notes** before installing it: an
5
5
  upgrade can rewrite what the lab keeps in your data directory, and an older
6
6
  version cannot always read it back.
7
7
 
8
+ ## 0.13.0
9
+
10
+ ### Upgrade notes
11
+
12
+ - Pipelines and datasets keep their versions (17 and 9), so nothing in your
13
+ data directory is rewritten. A pipeline that uses the new Refused metric
14
+ needs 0.13.0 to grade it.
15
+
16
+ ### Added
17
+
18
+ - A Refused metric, for a case that expects the target's call to be refused.
19
+ - A PDF item goes to the model whole, as a document; a connection that can't
20
+ read one is refused before the run.
21
+ - AI Connections and Connection Profiles select several rows to remove at
22
+ once, with one Undo; Connection Profiles show their connection.
23
+ - Copy or move… on an eval group's, a filter set's and a Source's own page.
24
+
25
+ ### Changed
26
+
27
+ - One date format everywhere, and Home lists recent runs.
28
+ - Dropdowns that name things read A–Z; Model, Environment and Flow open the
29
+ Switcher.
30
+ - Empty values read as a short parenthetical, such as (No grader), and
31
+ explanations are shorter.
32
+ - A run's jobs sit in one Jobs group, with Add job inside it.
33
+ - A Supplied Source takes several files in one Upload.
34
+ - Enter in a dialog's field presses its main button; in Run settings it
35
+ leaves the field.
36
+ - Stop reads Stopping… until the run ends, and History can stop a run.
37
+
38
+ ### Fixed
39
+
40
+ - An errored result fails on its error and is not graded.
41
+ - Metrics and the grader read the reply after its Pre-format filters, and
42
+ Results shows that reply.
43
+ - A linked group whose grader is gone can't run, and Results says why a
44
+ graded metric never ran; an off-scale grader score is the grader's error.
45
+ - An Edited target whose expression doesn't parse is refused before the run.
46
+ - Pin names the newest version the server holds.
47
+ - Demo targets answer on a connection the workspace can use.
48
+ - Loop repeats a live capture, and the Comment stays with its pipeline.
49
+ - A Re-sync that needed a Microsoft sign-in runs once the page is back.
50
+ - A dynamic Source's fetch names the lab as its User-Agent.
51
+ - Emptying the trash leaves a connection shared only with the purged
52
+ workspace shared with none, not All; a lab import's rollback leaves
53
+ another import's connection intact.
54
+ - A stored Echo connection is no longer listed beside the built-in Echo
55
+ target.
56
+
8
57
  ## 0.12.0
9
58
 
10
59
  ### Upgrade notes
package/lab/VERSION CHANGED
@@ -1 +1 @@
1
- 0.12.0 (2026.10.12-718)
1
+ 0.13.0 (2026.10.13-794)
@@ -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";
@@ -607,6 +607,9 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
607
607
 
608
608
 
609
609
 
610
+
611
+
612
+
610
613
 
611
614
 
612
615
  /** How much of one text a run keeps -- a reply's body, a grader's prompt or
@@ -897,6 +900,14 @@ export function kept(text , max = KEPT_CHARS) {
897
900
 
898
901
 
899
902
 
903
+
904
+
905
+
906
+
907
+
908
+
909
+
910
+
900
911
 
901
912
 
902
913
 
@@ -1690,9 +1701,17 @@ function datasetRules(body ) {
1690
1701
 
1691
1702
 
1692
1703
 
1704
+
1705
+
1706
+
1707
+
1693
1708
 
1694
1709
 
1695
1710
 
1711
+
1712
+
1713
+
1714
+
1696
1715
 
1697
1716
 
1698
1717
 
@@ -1993,6 +2012,35 @@ function asNumber(v ) {
1993
2012
  // a tuned local engine is a llama.cpp profile with its settings.
1994
2013
  const CONNECTION_TYPES = Object.create(null);
1995
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
+
1996
2044
  /** The key [key] as the header most endpoints expect, when there is one. */
1997
2045
  const bearer = (key ) => (key ? { Authorization: "Bearer " + key } : {});
1998
2046
 
@@ -2105,6 +2153,13 @@ CONNECTION_TYPES.recorded = {
2105
2153
  request: sendsNothing, parseReply: sendsNothing, listModels: sendsNothing, parseModels: () => [],
2106
2154
  };
2107
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
+
2108
2163
  /** A local type's answer to one stage, as a model's would come back. An item
2109
2164
  with no text of its own (Prompt only) is answered from the prompt, and
2110
2165
  that reply is read against nothing -- the prompt is the reply, not an
@@ -2120,9 +2175,19 @@ function localAnswer(conn , text , sent , record
2120
2175
  const chatSchema = (body , schema ) =>
2121
2176
  ({ ...body, response_format: { type: "json_schema", json_schema: { name: "reply", strict: true, schema } } });
2122
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
+
2123
2186
  CONNECTION_TYPES["openai-compatible"] = {
2124
2187
  id: "openai-compatible", label: "OpenAI-compatible",
2125
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 ?? "")),
2126
2191
  settings: [
2127
2192
  // A reasoning model refuses a temperature.
2128
2193
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "",
@@ -2145,11 +2210,14 @@ CONNECTION_TYPES["openai-compatible"] = {
2145
2210
  ...(predict == null ? {} : hosted ? { max_completion_tokens: predict } : { max_tokens: predict }),
2146
2211
  messages: [{ role: "user", content: [
2147
2212
  { type: "text", text: prompt },
2148
- ...(dataUrl ? [{ type: "image_url", image_url: { url: dataUrl } }] : []),
2213
+ ...(dataUrl ? [chatAttachment(dataUrl)] : []),
2149
2214
  ] }],
2150
2215
  },
2151
2216
  };
2152
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,
2153
2221
  structured: chatSchema,
2154
2222
  parseReply: chatReply,
2155
2223
  listModels, parseModels: idsFrom,
@@ -2174,15 +2242,15 @@ CONNECTION_TYPES.anthropic = {
2174
2242
  answers: ["prompt", "request"],
2175
2243
  speaks: () => "anthropic",
2176
2244
  // The Messages API: the x-api-key and anthropic-version headers, base64
2177
- // image content blocks, and /v1/models for the list.
2245
+ // image and document content blocks, and /v1/models for the list.
2246
+ documents: true,
2178
2247
  request(conn, prompt, dataUrl, base, key) {
2179
2248
  const temp = asNumber(conn.temperature);
2180
2249
  const predict = asNumber(conn.maxTokens) ?? ANTHROPIC_MAX_TOKENS;
2181
2250
  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
- }
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 } });
2186
2254
  content.push({ type: "text", text: prompt });
2187
2255
  return {
2188
2256
  url: base + "/messages",
@@ -2227,6 +2295,7 @@ const SPEED_IMAGE = "before", SPEED_CACHE = "off", SPEED_THINKING = "off";
2227
2295
  CONNECTION_TYPES["llama.cpp"] = {
2228
2296
  id: "llama.cpp", label: "llama.cpp",
2229
2297
  description: "A llama-server you run, with its sampler settings.",
2298
+ keyOptional: () => true,
2230
2299
  settings: [
2231
2300
  { key: "temperature", label: "Temperature", control: "text", default: "", hint: "" },
2232
2301
  { key: "seed", label: "Seed", control: "text", default: "", hint: "" },
@@ -2511,6 +2580,7 @@ CONNECTION_TYPES.http = {
2511
2580
  ...Object.entries(HTTP_APIS).map(([value, a]) => ({ value, label: a.label }))] },
2512
2581
  ],
2513
2582
  answers: ["request"],
2583
+ keyOptional: (conn) => conn.auth === "none",
2514
2584
  speaks(conn) {
2515
2585
  const api = String(conn.requestFormat || "") || httpApiFor(originBase(conn.url));
2516
2586
  return HTTP_APIS[api] && api !== "raw" ? api : null;
@@ -2797,7 +2867,7 @@ function readCase(kase , res , plain = fa
2797
2867
  }
2798
2868
  return r ? [r ] : [];
2799
2869
  });
2800
- const s = scoreOf(metrics) ?? { pass: true, score: null, metrics };
2870
+ const s = erroredScore(scoreOf(metrics), res.error) ?? { pass: !res.error, score: null, metrics };
2801
2871
  const counted = metrics.filter(r => r.weight !== 0);
2802
2872
  return { ...s, metrics, found: s.found ?? [], missed: s.missed ?? [], invented: s.invented ?? [],
2803
2873
  discarded: !!res.error, count: res.error ? 0 : (res.terms || []).length,
@@ -3195,6 +3265,7 @@ async function runPipeline(stages , dataUrl , cal
3195
3265
  const pre = applyFilterSet(st.before || [], { body: res.raw ?? "", items: [], instruction: against }, "text");
3196
3266
  const by = (filter , stage , rejected ) => ({ filter, stage, job: i + 1, rejected });
3197
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 };
3198
3269
  const read = reader.read(pre.body, { echo: against, parsing: opts.parsing, settings: st.settings });
3199
3270
  if (read.error) return result({ error: named(read.error) });
3200
3271
  const mod = applyModifiers(mods[i], kind, read.value, { instruction: against, body: read.body ?? res.raw ?? "" });
@@ -3731,16 +3802,17 @@ function onlyFields(obj , at , allowed , bad
3731
3802
 
3732
3803
  // A Source is referenced, with the two numbers that decide how many items a
3733
3804
  // 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.
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.
3736
3808
  const FILE_LIBRARY_TEXT = [".txt", ".md", ".csv"] ;
3737
3809
  SOURCE_TYPES.files = {
3738
3810
  label: "File Library",
3739
3811
  icon: "folder",
3740
- description: "A folder of images and text files you upload.",
3812
+ description: "A folder of images, PDFs and text files you upload.",
3741
3813
  group: "Files",
3742
3814
  noun: "file",
3743
- 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],
3744
3816
  sendsImage: true,
3745
3817
  hasActions: false,
3746
3818
  itemText: name => FILE_LIBRARY_TEXT.some(ext => name.toLowerCase().endsWith(ext)),
@@ -3757,7 +3829,9 @@ SOURCE_TYPES.workflow = {
3757
3829
  icon: "workflow",
3758
3830
  description: "A cloud flow, with records of its steps' calls from its run history.",
3759
3831
  group: "Workflows",
3760
- 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",
3761
3835
  uploads: [".json"],
3762
3836
  sendsImage: false,
3763
3837
  hasActions: true,
@@ -3846,7 +3920,7 @@ const API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
3846
3920
  DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3847
3921
  id: "api", // provider: its id, the row's config.provider
3848
3922
  label: "API call",
3849
- icon: "square-code",
3923
+ icon: "arrow-left-right",
3850
3924
  noun: "call",
3851
3925
  tier: "pull",
3852
3926
  config: ["method", "url", "reqHeaders", "body", "connection"],
@@ -4116,7 +4190,7 @@ LEGACY_TESTS.single = {
4116
4190
  const raws = [], terms = [];
4117
4191
  for (const r of ress) {
4118
4192
  if (!r) continue;
4119
- raws.push(r.transcript?.[r.transcript.length - 1]?.got ?? r.raw ?? "");
4193
+ raws.push(gradedText(r));
4120
4194
  terms.push(...(r.terms || []));
4121
4195
  }
4122
4196
  const s = scoreSingle(t, terms, raws, !(kind != null && OUTPUT_KINDS[kind]?.terms));
@@ -4181,11 +4255,32 @@ function metricsProblems(list , at , bad ) {
4181
4255
  /** Whether any of [list] asks a grader. */
4182
4256
  const gradedIn = (list ) => Array.isArray(list) && list.some((m ) => !!METRICS[m?.type]?.graded);
4183
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
+
4184
4278
  /** What a metric reads of one result. */
4185
4279
  function metricInput(res , kase , more ) {
4186
4280
  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,
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,
4189
4284
  asked: last?.sent ?? null,
4190
4285
  // A case that has been given its own expected reply ("Take B as
4191
4286
  // expected") is compared with that; otherwise the Source's recorded
@@ -4201,7 +4296,7 @@ function runInput(ress , plain )
4201
4296
  let ms = 0;
4202
4297
  for (const r of ress) {
4203
4298
  if (!r) continue;
4204
- replies.push(r.transcript?.[r.transcript.length - 1]?.got ?? r.raw ?? "");
4299
+ replies.push(gradedText(r));
4205
4300
  terms.push(...(r.terms || []));
4206
4301
  ms += r.ms ?? 0;
4207
4302
  }
@@ -4217,6 +4312,19 @@ function readMetric(m , input , ctx )
4217
4312
  const label = (isStr(m.metric) && m.metric.trim()) || entry?.label || m.type;
4218
4313
  if (!entry) return { type: m.type, label, weight, pass: false, score: 0, reason: `"${m.type}" is not a metric this lab has` };
4219
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
+ }
4220
4328
  const done = (o ) => {
4221
4329
  if (!o) return null;
4222
4330
  const turned = m.not ? { ...o, pass: !o.pass, score: entry.counts ? -o.score : 1 - o.score, reason: `not: ${o.reason}` } : o;
@@ -4249,6 +4357,18 @@ function readMetric(m , input , ctx )
4249
4357
  } catch (e) { return timed(failed(e)); }
4250
4358
  }
4251
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
+
4252
4372
  /** An eval's score from its metrics' readings, the way [mode] says: all must
4253
4373
  pass, or weighted points against [threshold]. What a case metric found,
4254
4374
  missed and invented is carried up, for a run's totals. Null for none. */
@@ -4277,7 +4397,7 @@ async function readMetrics(list , input , ctx
4277
4397
  const r = await readMetric(m, input, ctx);
4278
4398
  if (r) metrics.push(r);
4279
4399
  }
4280
- return scoreOf(metrics, mode, threshold);
4400
+ return erroredScore(scoreOf(metrics, mode, threshold), input.error);
4281
4401
  }
4282
4402
 
4283
4403
  /** [list] read over a whole run, synchronously: each metric over the run's
@@ -4331,14 +4451,7 @@ LEGACY_TESTS.metrics = {
4331
4451
  // The eval's own grader, or the lab's.
4332
4452
  const grader = isRef(t.grader) ? t.grader : isRef(ctx.grader) ? ctx.grader : null;
4333
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");
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
- }
4454
+ graderProblems(grader, ctx, bad);
4342
4455
  },
4343
4456
  rules: t => (Array.isArray(t.metrics) ? t.metrics : []).map((m , x ) => ({
4344
4457
  key: `m${x}`, label: (isStr(m.metric) && m.metric) || METRICS[m.type]?.label || m.type,
@@ -4450,6 +4563,11 @@ EVAL_TYPES.group = {
4450
4563
  if (held && t.pin != null && typeof held.versions === "number" && t.pin > held.versions) {
4451
4564
  bad.push(`${g.name || g.id} has no version ${t.pin}`);
4452
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);
4453
4571
  return;
4454
4572
  }
4455
4573
  if (!isObj(own)) return void bad.push("an eval links a Library group, or holds its own");
@@ -4466,14 +4584,7 @@ EVAL_TYPES.group = {
4466
4584
  // The group's own grader, or the lab's.
4467
4585
  const grader = isRef(own.grader) ? own.grader : isRef(ctx.grader) ? ctx.grader : null;
4468
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");
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
- }
4587
+ graderProblems(grader, ctx, bad);
4477
4588
  },
4478
4589
  rules: t => ownList(t).map((m , x ) => ({
4479
4590
  key: `m${x}`, label: (isStr(m.metric) && m.metric) || METRICS[m.type]?.label || m.type,
@@ -4864,8 +4975,12 @@ STEP_TYPES.httpRequest = {
4864
4975
  }
4865
4976
  }
4866
4977
  if (isStr(step.prompt)) {
4867
- try { api.place(step.body, step ); }
4868
- 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}`); }
4869
4984
  }
4870
4985
  },
4871
4986
  };
@@ -4890,7 +5005,6 @@ STEP_TYPES.echo = {
4890
5005
  // the flow reads one. No profile, no prompt -- the record is the reply.
4891
5006
  STEP_TYPES.recorded = {
4892
5007
  label: "Recorded", slot: "target", asks: "nothing", local: true, replaces: "recorded",
4893
- description: "Pre-recorded from previous workflow runs, replays only.",
4894
5008
  firstJobOnly: "a call's recorded reply is job 1's",
4895
5009
  in: "item", out: "text",
4896
5010
  apply: "runPipeline",
@@ -6082,6 +6196,10 @@ function validatePipeline(input , ctx = {}) {
6082
6196
  ? (c.sources?.(content.ref?.id)?.files ?? content.files ?? null) : null;
6083
6197
  const nonText = (contentFiles || []).map(f => String((f )?.name ?? f))
6084
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;
6085
6203
  targets.forEach((_, i) => {
6086
6204
  const conns = doc.jobs.map((_ , k ) => (targetEntryOf(doc, i, k)?.local ? localConnOf(targetEntryOf(doc, i, k)) : lookup(targetAnswerOf(doc, i, k) )));
6087
6205
  conns.forEach((p , k ) => {
@@ -6111,6 +6229,10 @@ function validatePipeline(input , ctx = {}) {
6111
6229
  const name = targetAnswerOf(doc, i, k)?.connection.name || type .label;
6112
6230
  bad.push(`${n > 1 ? `${jobLabel(doc, k)}, ` : ""}${targetLabel(doc, i)}: ${name} can't send an ${HTTP_APIS[api]?.label ?? api} request`);
6113
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`);
6235
+ }
6114
6236
  });
6115
6237
  // The model is the profile's unless the target's step carries it.
6116
6238
  const bare = conns.findIndex((p , k ) => p && c.profiles && !local(p)
@@ -6913,7 +7035,9 @@ function scenarioEvals(run , i , items
6913
7035
  return evals.map((t, j) => {
6914
7036
  const type = EVAL_TYPES[t.type];
6915
7037
  const whole = isWholeRun(type, t);
6916
- 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,
6917
7041
  verdict: null , rules: type?.rules?.(t) ?? [], items: cells.map(() => null) ,
6918
7042
  ran: 0, passed: 0, skippedItems: 0 };
6919
7043
  if (skipRest) {
@@ -7053,6 +7177,11 @@ function validateEvals(ev , files
7053
7177
  if (scored.length > 1) say("expects its answer discarded, and states something the answer should hold too");
7054
7178
  continue;
7055
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
+ }
7056
7185
  const wants = scored.filter(m => !m.not && (m.type === "contains-all" || m.type === "contains-any"));
7057
7186
  const forbids = scored.filter(m => m.not && m.type === "contains");
7058
7187
  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 +7295,7 @@ export {
7166
7295
  loopReplyError, preparedSize,
7167
7296
  termIn, forbiddenIn, readCase, gradedSetFrom, caseItem, caseMetrics, metricLines, upgradeDatasetBody, takeAsExpected, readGroup, readGroupRun, datasetRules, upgradeResults, emptyTally, addToTally, isHostedUrl,
7168
7297
  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,
7298
+ CONNECTION_TYPES, DOCUMENT_TYPES, documentTypeOf, documentUrl, attachmentOf, HTTP_APIS, httpApiFor, httpRequestOf, httpReplyOf, readFlowReply, WdlError, localAnswer, typeOf, listedConnection, profileType, convertProfile, splitOllama, DECODING_KEYS, apiBase,
7170
7299
  EDGE_448, budgetLabel, IMAGE_FORMATS, encoderQuality,
7171
7300
  tallyPercent, runPipeline, validateEvals, evalsWarnings, evalsJson, readList,
7172
7301
  scoreSingle, singleRules, parseCount, lengthHolds, countHolds, COUNT_OPS, LENGTH_OPS,
@@ -7180,7 +7309,7 @@ CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWord
7180
7309
  contentOf, withContent, replyOf,
7181
7310
  targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
7182
7311
  tokensOf, withTokens, sendsImage, withImage, flowStepOf, requestStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7183
- filterSetsOf, withFilterSets, stageModifiers, beforeFilters,
7312
+ filterSetsOf, withFilterSets, stageModifiers, beforeFilters, gradedText,
7184
7313
  blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
7185
7314
  scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
7186
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 ) {
@@ -293,6 +293,8 @@ export const FILTER_SEPARATORS = ["comma", "lines", "semicolon", "none"]
293
293
 
294
294
 
295
295
 
296
+
297
+
296
298
 
297
299
 
298
300
  /** A base filter type (#334's decision: a registry, not an enum). A reader
@@ -471,19 +473,20 @@ export function applyFilter(filter , reply ) {
471
473
  export function applyFilterSet(filters , reply , stage ) {
472
474
  const instruction = reply.instruction ?? "";
473
475
  let body = reply.body, items = reply.items;
474
- const dropped = [];
476
+ const dropped = [], rewrote = [];
475
477
  let emptied ;
476
478
  for (const filter of filters) {
477
479
  const scope = FILTER_TYPES[filter.type]?.scope;
478
480
  if (stage && scope && (scope === "text") !== (stage === "text")) continue;
479
481
  const out = applyFilter(filter, { body, items, instruction });
482
+ if (out.body !== body) rewrote.push(filter.id);
480
483
  body = out.body;
481
484
  if (items.length && !out.items.length) emptied = filter.id;
482
485
  items = out.items;
483
486
  dropped.push(...out.dropped);
484
487
  if (out.reject != null) return { body, items, dropped, reject: out.reject };
485
488
  }
486
- return { body, items, dropped, reject: null, ...(emptied ? { emptied } : {}) };
489
+ return { body, items, dropped, reject: null, ...(emptied ? { emptied } : {}), ...(rewrote.length ? { rewrote } : {}) };
487
490
  }
488
491
 
489
492
  // A newline always ends an item, as it does in the real csv/lines parse