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 +49 -0
- package/lab/VERSION +1 -1
- package/lab/evals-core.mjs +170 -41
- package/lab/flows/wdl.mjs +19 -0
- package/lab/kinds/list.mjs +5 -2
- package/lab/metrics/builtin.mjs +34 -7
- package/lab/run-evals.js +30 -11
- package/lab/server.py +137 -28
- package/lab/web/dist/assets/gallery-DpaqOg9Z.js +5 -0
- package/lab/web/dist/assets/main-BSw-jyYD.css +1 -0
- package/lab/web/dist/assets/main-CrB8wPZn.js +17 -0
- package/lab/web/dist/assets/tokens-CrjDMCbz.js +60 -0
- package/lab/web/dist/assets/tokens-DDOONQon.css +1 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-CFntjQFG.js +0 -5
- package/lab/web/dist/assets/main-BlTG2i20.css +0 -1
- package/lab/web/dist/assets/main-D6MNWqak.js +0 -17
- package/lab/web/dist/assets/tokens-DwzzdSIF.css +0 -1
- package/lab/web/dist/assets/tokens-bvuNFuYg.js +0 -60
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.
|
|
1
|
+
0.13.0 (2026.10.13-794)
|
package/lab/evals-core.mjs
CHANGED
|
@@ -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 ? [
|
|
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
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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:
|
|
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,
|
|
3735
|
-
// extensions are server.py's FILE_TYPES, which the
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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 =
|
|
4188
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4868
|
-
|
|
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
|
-
|
|
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 ) {
|
package/lab/kinds/list.mjs
CHANGED
|
@@ -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
|