evals-lab 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -2
  3. package/bin/run.js +4 -2
  4. package/lab/VERSION +1 -1
  5. package/lab/demo/CREDITS.md +14 -0
  6. package/lab/demo/datasets/demo-1.json +2 -2
  7. package/lab/demo/datasets/demo-2.json +2 -2
  8. package/lab/demo/datasets/documents-type.json +79 -0
  9. package/lab/demo/datasets/documents-valid.json +117 -0
  10. package/lab/demo/datasets/sentiment.json +2 -2
  11. package/lab/demo/datasets/weather-alerts.json +2 -2
  12. package/lab/demo/datasets/wordings.json +1 -1
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +4 -4
  15. package/lab/demo/pipelines/demo-2.json +4 -4
  16. package/lab/demo/pipelines/documents.json +102 -0
  17. package/lab/demo/pipelines/sentiment.json +4 -4
  18. package/lab/demo/pipelines/weather-alerts.json +9 -5
  19. package/lab/demo/pipelines/wordings.json +3 -3
  20. package/lab/demo/sources/Demo 1/source.json +1 -1
  21. package/lab/demo/sources/Demo 2/source.json +1 -1
  22. package/lab/demo/sources/documents/invoice.pdf +65 -0
  23. package/lab/demo/sources/documents/letter.pdf +63 -0
  24. package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
  25. package/lab/demo/sources/documents/receipt.pdf +62 -0
  26. package/lab/demo/sources/documents/report.pdf +59 -0
  27. package/lab/demo/sources/documents/source.json +3 -0
  28. package/lab/demo/sources/documents/truncated.pdf +19 -0
  29. package/lab/demo/sources/sentiment/source.json +1 -1
  30. package/lab/demo/sources/weather-alerts/source.json +1 -1
  31. package/lab/evals-core.mjs +217 -48
  32. package/lab/flows/record.mjs +5 -4
  33. package/lab/kinds/list.mjs +72 -21
  34. package/lab/run-evals.js +3 -1
  35. package/lab/server.py +536 -85
  36. package/lab/web/dist/assets/gallery-qrf0ZGXK.js +5 -0
  37. package/lab/web/dist/assets/main-D6xARg2A.js +16 -0
  38. package/lab/web/dist/assets/main-xVU6J7Gi.css +1 -0
  39. package/lab/web/dist/assets/tokens-C8dqXDHN.js +62 -0
  40. package/lab/web/dist/assets/tokens-tQ7zTPkd.css +1 -0
  41. package/lab/web/dist/gallery.html +3 -3
  42. package/lab/web/dist/index.html +4 -4
  43. package/package.json +1 -1
  44. package/lab/web/dist/assets/gallery-DpaqOg9Z.js +0 -5
  45. package/lab/web/dist/assets/main-BSw-jyYD.css +0 -1
  46. package/lab/web/dist/assets/main-CrB8wPZn.js +0 -17
  47. package/lab/web/dist/assets/tokens-CrjDMCbz.js +0 -60
  48. package/lab/web/dist/assets/tokens-DDOONQon.css +0 -1
@@ -199,12 +199,23 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
199
199
 
200
200
 
201
201
 
202
+
203
+
204
+
205
+
206
+
202
207
   
203
208
 
204
209
 
205
210
 
206
211
 
207
212
 
213
+ /** One kept response of a dynamic Source's item (#596). */
214
+
215
+
216
+
217
+
218
+
208
219
 
209
220
 
210
221
 
@@ -347,17 +358,26 @@ import list, { ECHO, CASE_MODES, legacyTagsOut, legacyTagsModifiers, cleaningFil
347
358
  * at. `filters` are tried in order -- each drop-item narrows the items the
348
359
  * next reads, and the first reject-reply that fires gates the answer.
349
360
  */
350
- /** Which side of Response Format a filter runs on (#405): `text` the raw reply before
351
- it is read into items (rewrite-text / reject-text), `items` the items after
352
- (drop-item, rewrite-item, keep-first, reject-reply). A filter's type decides
353
- its stage; a set belongs to none (#432), and runs its share of filters in
354
- each stage it is linked in. */
361
+ /** Which side of Response Format a filter step sits on (#405): `text`
362
+ before it, on the raw reply (Pre-format), `items` after it (Post-format).
363
+ It also names what a filter reads: the reply's text (rewrite-text /
364
+ reject-text) or the items (drop-item, rewrite-item, keep-first,
365
+ reject-reply). A text filter runs at either stage, an item filter only
366
+ Post-format after a kind that reads items (#557, `stageReads`); a set
367
+ belongs to no stage (#432), and runs its share in each it is linked in. */
355
368
 
356
369
 
357
370
  /** The stage a filter type's scope belongs to: text filters read the raw
358
371
  reply; item, list and answer filters read the items it is read into. */
359
372
  export const stageOfScope = (scope ) => (scope === "text" ? "text" : "items");
360
373
 
374
+ /** What job's [stage] filters read when it answers as [kind] (#557):
375
+ Pre-format the text; Post-format the items after a kind that reads
376
+ items, and the text after any other. A filter runs there when its
377
+ `stageOfScope` is this. */
378
+ export const stageReads = (stage , kind ) =>
379
+ stage === "items" && OUTPUT_KINDS[kind ?? ""]?.offers?.includes("items") ? "items" : "text";
380
+
361
381
  /** A filter stage as the page names it: before the reply is formatted, or after. */
362
382
  export const FILTER_STAGE_LABEL = { text: "Pre-format", items: "Post-format" };
363
383
 
@@ -555,6 +575,10 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
555
575
 
556
576
 
557
577
 
578
+
579
+
580
+
581
+
558
582
 
559
583
 
560
584
 
@@ -586,7 +610,15 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
586
610
 
587
611
 
588
612
 
613
+
614
+
615
+
616
+
589
617
 
618
+
619
+ /** A reply the reply-token limit stopped: the limit the request sent, or
620
+ null where it sent none and the server's own applied. */
621
+
590
622
 
591
623
  /** The transport: a stage's prompt and image in, its reply out. */
592
624
 
@@ -607,9 +639,15 @@ export const FILTER_STAGE_LABEL = { text: "Pre-form
607
639
 
608
640
 
609
641
 
610
-
611
642
 
643
+
644
+
612
645
 
646
+
647
+
648
+
649
+
650
+
613
651
 
614
652
 
615
653
  /** How much of one text a run keeps -- a reply's body, a grader's prompt or
@@ -675,6 +713,7 @@ export function kept(text , max = KEPT_CHARS) {
675
713
 
676
714
 
677
715
 
716
+
678
717
 
679
718
 
680
719
 
@@ -1204,10 +1243,14 @@ const platformOf = (src
1204
1243
 
1205
1244
 
1206
1245
 
1207
-
1208
-
1209
-
1246
+
1247
+
1248
+
1210
1249
 
1250
+
1251
+
1252
+
1253
+
1211
1254
 
1212
1255
 
1213
1256
 
@@ -1697,6 +1740,10 @@ function datasetRules(body ) {
1697
1740
 
1698
1741
 
1699
1742
 
1743
+
1744
+
1745
+
1746
+
1700
1747
 
1701
1748
 
1702
1749
 
@@ -2106,6 +2153,7 @@ function ollamaType(id , label , cloud ) {
2106
2153
  const r = j ;
2107
2154
  return { raw: r?.message?.content ?? "", finishReason: r?.done_reason ?? null };
2108
2155
  },
2156
+ cutOff: { reason: "length", limit: (body) => asNumber((body.options )?.num_predict) },
2109
2157
  listModels: (base, key) => listModels(base, cloud ? key : null),
2110
2158
  parseModels: idsFrom,
2111
2159
  ...(cloud ? { url: "https://ollama.com" } : { keyless: true }),
@@ -2220,6 +2268,7 @@ CONNECTION_TYPES["openai-compatible"] = {
2220
2268
  documents: true,
2221
2269
  structured: chatSchema,
2222
2270
  parseReply: chatReply,
2271
+ cutOff: { reason: "length", limit: (body) => asNumber(body.max_completion_tokens ?? body.max_tokens) },
2223
2272
  listModels, parseModels: idsFrom,
2224
2273
  };
2225
2274
 
@@ -2271,6 +2320,7 @@ CONNECTION_TYPES.anthropic = {
2271
2320
  const raw = (r?.content || []).filter(b => b.type === "text").map(b => b.text).join("");
2272
2321
  return { raw, finishReason: r?.stop_reason ?? null };
2273
2322
  },
2323
+ cutOff: { reason: "max_tokens", limit: (body) => asNumber(body.max_tokens) },
2274
2324
  // A flow's request, keyed as the Messages API is -- what the HTTP endpoint
2275
2325
  // type's Anthropic template sets. [base] is the /v1 base words hang off;
2276
2326
  // the step's path is the whole path production called, so it goes to the
@@ -2354,6 +2404,7 @@ CONNECTION_TYPES["llama.cpp"] = {
2354
2404
  // llama-server takes OpenAI's response_format, a schema and all.
2355
2405
  structured: chatSchema,
2356
2406
  parseReply: chatReply,
2407
+ cutOff: { reason: "length", limit: (body) => asNumber(body.n_predict) },
2357
2408
  listModels, parseModels: idsFrom,
2358
2409
  // A reply that arrived is still a failure when this profile asked for the
2359
2410
  // Speed looping check (today's speedReplyError, now an opt-in setting).
@@ -2728,6 +2779,18 @@ function connectionReply(conn , j )
2728
2779
  }
2729
2780
 
2730
2781
  /** Why a reply [conn] received is a failure though it arrived, or null. */
2782
+ /** How a reply finished, as a stage keeps it (#597): the reason [conn]'s
2783
+ API gave, and -- where that reason is its type's reply-token limit -- the
2784
+ limit [body], the request sent, carried. A type that says no reason
2785
+ keeps nothing. */
2786
+ function replyFinish(conn , body , finishReason ) {
2787
+ if (!finishReason) return {};
2788
+ const cut = typeEntry(conn).cutOff;
2789
+ if (!cut || finishReason !== cut.reason) return { finish: finishReason };
2790
+ const limit = body && typeof body === "object" ? cut.limit(body ) : null;
2791
+ return { finish: finishReason, stopped: { limit } };
2792
+ }
2793
+
2731
2794
  function connectionReplyProblem(conn , raw , finishReason ) {
2732
2795
  return typeEntry(conn).replyProblem?.(conn, raw, finishReason) ?? null;
2733
2796
  }
@@ -3253,7 +3316,8 @@ async function runPipeline(stages , dataUrl , cal
3253
3316
  total += res.ms;
3254
3317
  transcript.push({ n: i + 1, kind, conn: res.conn ?? null, withImage, sent: res.sent ?? sent,
3255
3318
  got: res.raw ?? "", ms: res.ms, error: res.error, ...(res.said != null ? { said: res.said } : {}),
3256
- ...(res.body != null ? { body: kept(res.body) } : {}) });
3319
+ ...(res.body != null ? { body: kept(res.body) } : {}),
3320
+ ...(res.finish ? { finish: res.finish } : {}), ...(res.stopped ? { stopped: res.stopped } : {}) });
3257
3321
  if (res.error) return result({ error: named(res.error) });
3258
3322
  // A stage that failed is the end of the job: nothing after it is asked.
3259
3323
  // Handed nothing, the next stage still answers -- about an empty list --
@@ -3272,6 +3336,16 @@ async function runPipeline(stages , dataUrl , cal
3272
3336
  if (mod.reject) {
3273
3337
  return result({ error: named(`discarded: ${mod.reject}`), ...(mod.by ? { filteredBy: by(mod.by, "items", true) } : {}) });
3274
3338
  }
3339
+ // A kind that reads no items runs its Post-format filters on its text,
3340
+ // once read (#557); what they leave is what is graded.
3341
+ if (st.after?.length && typeof mod.value === "string") {
3342
+ const post = applyFilterSet(st.after, { body: mod.value, items: [], instruction: against }, "text");
3343
+ if (post.reject != null) return result({ error: named(`discarded: ${post.reject}`), filteredBy: by(post.reject, "items", true) });
3344
+ if (post.rewrote) {
3345
+ transcript[transcript.length - 1] .filtered = { got: post.body, by: [...(pre.rewrote || []), ...post.rewrote] };
3346
+ mod.value = post.body;
3347
+ }
3348
+ }
3275
3349
  // A filter that dropped the last item: what a zero result is explained by.
3276
3350
  const emptied = mod.by && reader.terms && !reader.terms(mod.value).length ? { filteredBy: by(mod.by, "items", false) } : {};
3277
3351
  const nothing = handsOn ? reader.handsOn?.(mod.value) : null;
@@ -3280,7 +3354,9 @@ async function runPipeline(stages , dataUrl , cal
3280
3354
  held = { kind, value };
3281
3355
  const gone = [...(read.dropped || []), ...mod.dropped];
3282
3356
  echoed = (read.echoed || 0) + mod.dropped.filter(d => d.by === ECHO).length;
3283
- done.push({ reply: res.raw ?? "", rendered: reader.render?.(value) || {} });
3357
+ // {reply} and {stageN} hand on the text as its filters left it -- what
3358
+ // was graded -- never the raw reply a filter cleaned (#557).
3359
+ done.push({ reply: transcript[transcript.length - 1] .filtered?.got ?? res.raw ?? "", rendered: reader.render?.(value) || {} });
3284
3360
  if (!handsOn) {
3285
3361
  dropped = gone;
3286
3362
  return result({ echoed, dropped, ...emptied });
@@ -3380,7 +3456,7 @@ function applyModifiers (list , kind ,
3380
3456
  // 16: a target names an AI Connection and, optionally, a Connection Profile,
3381
3457
  // where it named one Target profile; a run's table is keyed by the pair
3382
3458
  // (answerKey) -- the Target profile split in the store as it opens (#445).
3383
- const PIPELINE_VERSION = 17 ;
3459
+ const PIPELINE_VERSION = 18 ;
3384
3460
 
3385
3461
  // Plain objects, so an entry is added by assignment and a reader never needs
3386
3462
  // to know which registered it.
@@ -3662,9 +3738,10 @@ function splitProfile(p )
3662
3738
  }
3663
3739
 
3664
3740
  /** [conn] with [profile]'s settings over it: the one set of settings a
3665
- request is built from, the shape a Target profile had. A profile sets
3666
- no connection field. */
3667
- function mergeAnswer(conn , profile ) {
3741
+ request is built from, the shape a Target profile had. A profile -- or
3742
+ (Default)'s settings, which are one without its id and name -- sets no
3743
+ connection field. */
3744
+ function mergeAnswer(conn , profile ) {
3668
3745
  const out = { ...conn };
3669
3746
  const scoped = connectionScoped(typeOf(conn ));
3670
3747
  for (const [k, v] of Object.entries(profile ?? {})) {
@@ -3681,7 +3758,7 @@ function mergeAnswer(conn , profile
3681
3758
 
3682
3759
 
3683
3760
  /** The key a run's table holds [connection] under, with [profile] -- or with
3684
- none, the lab's defaults. A grader names its connection bare, its id alone,
3761
+ none, (Default)'s settings. A grader names its connection bare, its id alone,
3685
3762
  so it never meets a target's pair (see `answersOf`). */
3686
3763
  const answerKey = (connection , profile ) => `${connection}.${profile ?? ""}`;
3687
3764
 
@@ -3716,9 +3793,13 @@ function targetAnswerKey(doc
3716
3793
  * answer key resolved to the settings it sends -- the profile's connection
3717
3794
  * where it names one, else the key's, with the profile's settings over it.
3718
3795
  * A grader's bare id resolves as the Target profile it named did: its
3719
- * connection under the profile split from it. Null where any part is gone.
3796
+ * connection under the profile split from it. One that names no profile, or
3797
+ * a grader with none split beside it, takes [fallback] instead -- (Default)'s
3798
+ * settings, which sit under no named profile, so a profile is still what it
3799
+ * sets (Setup's Used by counts the same, `defaultUses`). Null where any part
3800
+ * is gone.
3720
3801
  */
3721
- function answersOf(connections , profiles ) {
3802
+ function answersOf(connections , profiles , fallback ) {
3722
3803
  const connection = (id ) => connections.find(c => c?.id === id) ?? null;
3723
3804
  const profile = (id ) => profiles.find(p => p?.id === id) ?? null;
3724
3805
  const split = (id ) => {
@@ -3731,7 +3812,7 @@ function answersOf(connections , profiles
3731
3812
  const p = pid ? profile(pid) : null;
3732
3813
  if (pid && !p) return null;
3733
3814
  const c = connection(isRef(p?.connection) ? p .connection .id : at.connection);
3734
- return c ? mergeAnswer(c, p) : null;
3815
+ return c ? mergeAnswer(c, p ?? fallback) : null;
3735
3816
  };
3736
3817
  return { connection, profile, split, resolve };
3737
3818
  }
@@ -3884,15 +3965,15 @@ SOURCE_TYPES.dynamic = {
3884
3965
  };
3885
3966
 
3886
3967
  // HTTP GET (docs/dynamic-sources.md § 1): the safest pull provider. It fetches
3887
- // a URL through the relay server-side -- Snapshot now freezes one Call into the
3888
- // Source, Live produces one fresh at submit -- so the page drives only the
3968
+ // a URL through the relay server-side -- Fetch now keeps a response, Live
3969
+ // data fetches it again at submit -- so the page drives only the
3889
3970
  // create dialog and the Call labels; the fetch, redaction and token-refusal are
3890
3971
  // the server's (its SOURCE_PROVIDERS mirror in server.py).
3891
3972
  DYNAMIC_PROVIDERS["http-get"] = { // provider: the HTTP GET adapter's own id
3892
3973
  id: "http-get", // provider: its id, the row's config.provider
3893
3974
  label: "HTTP GET",
3894
3975
  icon: "square-code",
3895
- noun: "call",
3976
+ noun: "response",
3896
3977
  tier: "pull",
3897
3978
  config: ["url"],
3898
3979
  configProblems(config){
@@ -3921,7 +4002,7 @@ DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3921
4002
  id: "api", // provider: its id, the row's config.provider
3922
4003
  label: "API call",
3923
4004
  icon: "arrow-left-right",
3924
- noun: "call",
4005
+ noun: "response",
3925
4006
  tier: "pull",
3926
4007
  config: ["method", "url", "reqHeaders", "body", "connection"],
3927
4008
  configProblems(config){
@@ -3950,7 +4031,7 @@ DYNAMIC_PROVIDERS["api"] = { // provider: the API call adapter's own id
3950
4031
  // or an Upload… by hand, formalised as a provider. The lab fetches and executes
3951
4032
  // nothing: a Supplied item is named by the id the caller gives it (its file
3952
4033
  // name), which is what joins it to a case. It has no config and no server-side
3953
- // production, so no Snapshot now and no live; its Calls are the items handed in,
4034
+ // production, so no Fetch now and no live data; its Calls are the items handed in,
3954
4035
  // redacted on the way in like any other (server.py's take_dynamic). A hosted run
3955
4036
  // grades those Calls; a solely-Supplied Source with none is run only via the CLI.
3956
4037
  DYNAMIC_PROVIDERS["supplied"] = { // provider: the Supplied adapter's own id
@@ -3980,6 +4061,7 @@ DYNAMIC_PROVIDERS["file"] = { // provider: the File on disk adapter's own id
3980
4061
  label: "File on disk",
3981
4062
  icon: "file",
3982
4063
  noun: "file",
4064
+ produceLabel: "Read now",
3983
4065
  tier: "local",
3984
4066
  config: ["path"],
3985
4067
  configProblems(config){
@@ -4003,6 +4085,7 @@ DYNAMIC_PROVIDERS["command"] = { // provider: the Command adapter's own id
4003
4085
  label: "Command",
4004
4086
  icon: "terminal",
4005
4087
  noun: "call",
4088
+ produceLabel: "Run now",
4006
4089
  tier: "local",
4007
4090
  config: ["command", "args"],
4008
4091
  configProblems(config){
@@ -4085,11 +4168,14 @@ CONTENT_TYPES.source = {
4085
4168
  description: "Every file or record in a Source from the Library.",
4086
4169
  // `live` is a dynamic Source's per-run snapshot-vs-live override
4087
4170
  // (docs/dynamic-sources.md); meaningless for another kind, ignored there.
4088
- fields: ["type", "ref", "first", "loops", "live"],
4171
+ fields: ["type", "ref", "first", "loops", "live", "pin"],
4089
4172
  runFields: ["files", "revs"],
4090
4173
  validate(c, ctx, bad){
4091
4174
  if (!isRef(c.ref)) return void bad.push("a Source content has to name its Source as { id, name }");
4092
4175
  if (c.live != null && typeof c.live !== "boolean") bad.push("a Source content's live is true or false");
4176
+ if (c.pin != null && !(isObj(c.pin) && isStr(c.pin.name) && c.pin.name && Number.isInteger(c.pin.n) && c.pin.n >= 0)) {
4177
+ bad.push("a Source content's pin names a response as { name, n }");
4178
+ }
4093
4179
  if (c.first != null && !(Number.isInteger(c.first) && c.first > 0)) {
4094
4180
  bad.push("Run first wants a positive number, or empty for all");
4095
4181
  }
@@ -4269,7 +4355,7 @@ function erroredScore (s , error
4269
4355
  }
4270
4356
 
4271
4357
  /** The reply a metric grades and Results shows (#546): the last stage's
4272
- reply after its Pre-format filters, or the raw reply. */
4358
+ reply after its filters (#557), or the raw reply. */
4273
4359
  function gradedText(res ) {
4274
4360
  const last = res?.transcript?.at(-1);
4275
4361
  return last?.filtered?.got ?? last?.got ?? res?.raw ?? "";
@@ -5147,6 +5233,22 @@ function withContent (doc , content )
5147
5233
  return { ...doc, jobs: [withStep(first, "attachContent", content ? { type: "attachContent", content } : null), ...rest] };
5148
5234
  }
5149
5235
 
5236
+ /**
5237
+ * Whether target [i]'s words in job [k] have the item's text joined after
5238
+ * them (#597): job 1, a target that asks a prompt, text content -- a Source
5239
+ * of text items, or inline text, as its CONTENT_TYPES entry says -- and no
5240
+ * {text} in the words to place it (textPrompt). A later job's reply goes
5241
+ * only where a token places it, and an image or a document is sent as a part
5242
+ * of its own, so neither is joined. [ctx] reads a Source as validation does.
5243
+ */
5244
+ function contentJoined(doc , i , k ,
5245
+ ctx = {}) {
5246
+ if (k !== 0 || targetEntryOf(doc, i, k)?.asks !== "prompt") return false;
5247
+ if (String(targetStepOf(doc, i, k)?.prompt ?? "").includes("{text}")) return false;
5248
+ const content = contentOf(doc);
5249
+ return !!content && (CONTENT_TYPES[content.type]?.text?.(content, ctx) ?? null) != null;
5250
+ }
5251
+
5150
5252
  /** A document's targets, in column order. */
5151
5253
  function targetsOf(doc ) {
5152
5254
  return Array.isArray(doc?.targets) ? doc .targets : [];
@@ -5366,6 +5468,15 @@ function stageModifiers(job , resolve
5366
5468
  return out;
5367
5469
  }
5368
5470
 
5471
+ /** A job's Post-format filters for a run when its kind reads no items
5472
+ (#557), in document order: every link's on its `items` filters step, which
5473
+ a run applies to the text after the kind reads it. A kind that reads items
5474
+ has none here -- `stageModifiers` runs its Post-format links. */
5475
+ function afterFilters(job , resolve ) {
5476
+ if (stageReads("items", outOf(job)?.kind) !== "text") return [];
5477
+ return (filterStepOf(job, "items")?.filterSets || []).flatMap(link => linkFilters(link, resolve));
5478
+ }
5479
+
5369
5480
  /** A job's Pre-format (text) filters for a run (#405), in document order:
5370
5481
  the filters of every link on its `text` filters step, which a run applies to
5371
5482
  the raw reply before the kind reads it into items. [resolve] gives a Library
@@ -5533,6 +5644,12 @@ function versionProblem(doc ) {
5533
5644
 
5534
5645
 
5535
5646
 
5647
+
5648
+
5649
+
5650
+
5651
+
5652
+
5536
5653
 
5537
5654
 
5538
5655
  /**
@@ -5824,7 +5941,8 @@ function ownBodiesNow(doc ) {
5824
5941
 
5825
5942
  function upgradePipeline (doc , ctx = {}) {
5826
5943
  // A current document is read as it is, but for a profile reference the Runs
5827
- // tab saved whole (see profileRefs), which is cut back, and a step on a
5944
+ // tab saved whole (see profileRefs), which is cut back, a Read Reply's
5945
+ // leftover `preamble: false` (withoutLeftoverPreamble), and a step on a
5828
5946
  // profile a target step stands in for (localSteps).
5829
5947
  if (isObj(doc) && doc.version === PIPELINE_VERSION) {
5830
5948
  let out = doc ;
@@ -5832,12 +5950,16 @@ function upgradePipeline (doc , ctx = {}) {
5832
5950
  out = clone(out) ;
5833
5951
  profileRefs(out);
5834
5952
  }
5835
- return localSteps(ownBodiesNow(out), ctx) ;
5953
+ return localSteps(ownBodiesNow(withoutLeftoverPreamble(out)), ctx) ;
5836
5954
  }
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;
5955
+ if (!isObj(doc) || ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].includes(doc.version )) return doc;
5956
+ // Every version reads as 17 first, then as 18: a saved-mode Source content
5957
+ // pins one response (savedPinned).
5958
+ const eighteen = (d ) => localSteps(ownBodiesNow(savedPinned(d, ctx)), ctx) ;
5959
+ if (doc.version === 17) return eighteen(cutRefs(clone(doc) ));
5838
5960
  // Every version reads as 16 first, then as 17: its requests read by their
5839
5961
  // job's HTTP API (requestsOnJobApi).
5840
- const seventeen = (d ) => localSteps(ownBodiesNow(requestsOnJobApi(d)), ctx) ;
5962
+ const seventeen = (d ) => eighteen(requestsOnJobApi(d));
5841
5963
  if (doc.version === 16) return seventeen(cutRefs(clone(doc) ));
5842
5964
  // Every version reads as 15 first, then as 16: its Target profiles split
5843
5965
  // into AI Connections and Connection Profiles (answersFromProfiles).
@@ -5883,11 +6005,13 @@ function filtersFromCleaning(next ) {
5883
6005
  const isFilters = (st ) => isObj(st) && st.type === "filters";
5884
6006
  next.jobs = next.jobs.map((job ) => {
5885
6007
  if (!isObj(job) || !Array.isArray(job.steps)) return job;
5886
- const steps = job.steps ;
5887
- const readReply = steps.find(st => isObj(st) && st.type === "readReply") ;
6008
+ const readReply = job.steps.find((st ) => isObj(st) && st.type === "readReply") ;
5888
6009
  const preamble = !!(readReply && isObj(readReply.out) && readReply.out.preamble === true);
6010
+ // Stripped from every Read Reply, `false` included, before the job is
6011
+ // asked whether it has anything to migrate (#604).
6012
+ const steps = (job.steps ).map(withoutPreamble);
5889
6013
  const stale = steps.some(st => isFilters(st) && (st ).stage == null);
5890
- if (!steps.some(isCleaning) && !preamble && !stale) return job;
6014
+ if (!steps.some(isCleaning) && !preamble && !stale) return { ...job, steps };
5891
6015
  const jid = isStr(job.id) ? job.id : "job";
5892
6016
 
5893
6017
  // Walk the Responses steps, turning each into a chunk of items filters
@@ -5916,11 +6040,6 @@ function filtersFromCleaning(next ) {
5916
6040
  const out = [];
5917
6041
  let placed = false;
5918
6042
  for (const st of steps) {
5919
- if (isObj(st) && st.type === "readReply" && isObj(st.out) && "preamble" in st.out) {
5920
- const { preamble: _p, ...rest } = st.out ;
5921
- out.push({ ...st, out: rest });
5922
- continue;
5923
- }
5924
6043
  if (isCleaning(st) || isFilters(st)) {
5925
6044
  if (!placed) { if (textStep) out.push(textStep); if (itemsStep) out.push(itemsStep); placed = true; }
5926
6045
  continue;
@@ -5933,6 +6052,30 @@ function filtersFromCleaning(next ) {
5933
6052
  return next;
5934
6053
  }
5935
6054
 
6055
+ /** [st] without its List `preamble` setting, when it is a Read Reply holding
6056
+ one; [st] itself otherwise. */
6057
+ function withoutPreamble(st ) {
6058
+ if (!isObj(st) || st.type !== "readReply" || !isObj(st.out) || !("preamble" in st.out)) return st;
6059
+ const { preamble: _p, ...out } = st.out ;
6060
+ return { ...st, out };
6061
+ }
6062
+
6063
+ /** Whether a current document still carries a `preamble: false` that an
6064
+ upgrade before #604 left behind. It did nothing, so it is cut as the
6065
+ document is read; a `preamble: true` the lab never wrote at this version is
6066
+ left for validation to refuse. */
6067
+ function leftoverPreamble(doc ) {
6068
+ return Array.isArray(doc.jobs) && doc.jobs.some((job ) => isObj(job) && Array.isArray(job.steps)
6069
+ && job.steps.some((st ) => isObj(st) && st.type === "readReply" && isObj(st.out) && st.out.preamble === false));
6070
+ }
6071
+
6072
+ /** [doc] with each Read Reply's leftover `preamble: false` cut (leftoverPreamble). */
6073
+ function withoutLeftoverPreamble(doc ) {
6074
+ if (!leftoverPreamble(doc)) return doc;
6075
+ return { ...doc, jobs: doc.jobs.map((job ) => (!isObj(job) || !Array.isArray(job.steps) ? job
6076
+ : { ...job, steps: job.steps.map((st ) => (isObj(st) && isObj(st.out) && st.out.preamble === false ? withoutPreamble(st) : st)) })) };
6077
+ }
6078
+
5936
6079
  /** [doc] with no Metrics eval holding the `case` metric: a dataset's cases
5937
6080
  are metrics now (dataset body version 5), which an eval naming the
5938
6081
  dataset adds for each item, so the case's own metrics carry what that
@@ -5986,6 +6129,24 @@ function localSteps(doc , ctx ) {
5986
6129
  return next;
5987
6130
  }
5988
6131
 
6132
+ /**
6133
+ * Version 17 to 18 (#596): Saved response pins one response. A Source content
6134
+ * graded as saved -- `live` false, or unset over a Source whose default is
6135
+ * saved, which [ctx] says -- pins the Source's newest successful response
6136
+ * (owner), so a Source holding several grades one where it graded all. Live
6137
+ * data, a content that is not a Source's, or one [ctx] cannot say of, pins
6138
+ * none; a run document carries its files, and needs none.
6139
+ */
6140
+ function savedPinned(next , ctx ) {
6141
+ next.version = PIPELINE_VERSION;
6142
+ const content = contentOf(next ) ;
6143
+ if (!isObj(content) || content.type !== "source" || content.live === true || content.pin != null
6144
+ || isObj(next.profiles) || !isRef(content.ref) || !ctx.pinFor) return next;
6145
+ const pin = ctx.pinFor(content.ref.id, typeof content.live === "boolean" ? content.live : undefined);
6146
+ if (pin) content.pin = { name: pin.name, n: pin.n };
6147
+ return next;
6148
+ }
6149
+
5989
6150
  /**
5990
6151
  * Version 16 to 17 (#477): a request step has no HTTP API of its own; its
5991
6152
  * job's flow step says it. A step that named another -- Any endpoint over an
@@ -6439,7 +6600,11 @@ function resolvePipeline(doc ,
6439
6600
  run.profiles[key] = { ...conn, connection: cid };
6440
6601
  }
6441
6602
  const content = contentOf(run) ;
6442
- if (content?.type === "source") content.files = CONTENT_TYPES.source .expand (content, ctx.files);
6603
+ // Saved response grades its one pinned response (#596), whatever else the
6604
+ // Source holds; the server keeps that response's bytes with the run.
6605
+ if (content?.type === "source") {
6606
+ content.files = CONTENT_TYPES.source .expand (content, isObj(content.pin) && content.live !== true ? [content.pin.name] : ctx.files);
6607
+ }
6443
6608
  // Each Library group the evals read, as the version it grades with: the
6444
6609
  // body's fingerprint and, where the lab numbers them, `n` -- a pinned
6445
6610
  // link's pin.
@@ -6706,7 +6871,8 @@ const BUNDLE_DATASET = /^datasets\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/;
6706
6871
  * [doc] as a bundle's text files: `pipeline.yaml`, `profiles.yaml` and
6707
6872
  * `datasets/<slug>.json`. [ctx.connections] and [ctx.connectionProfiles]
6708
6873
  * are Setup's -- keys and all, none of which is written -- or
6709
- * [ctx.profiles], Target profiles to split as the store was; [ctx.grader]
6874
+ * [ctx.profiles], Target profiles to split as the store was, and
6875
+ * [ctx.profileDefault] (Default)'s settings; [ctx.grader]
6710
6876
  * the lab's grader, written into each eval that would have asked it, and
6711
6877
  * [ctx.datasets] each graded dataset by id, as `{ name, body }`. Each
6712
6878
  * connection asked is written resolved, under its answer key with the
@@ -6715,12 +6881,13 @@ const BUNDLE_DATASET = /^datasets\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/;
6715
6881
  * something else.
6716
6882
  */
6717
6883
  function exportBundle(doc , ctx
6718
-
6719
- = {})
6884
+
6885
+ = {})
6720
6886
  {
6721
6887
  const split = (ctx.profiles ?? []).map(splitProfile);
6722
6888
  const profiles = withSlugs([...(ctx.connections ?? []), ...split.map(x => x.connection)] );
6723
- const answers = answersOf(profiles, [...(ctx.connectionProfiles ?? []), ...split.flatMap(x => (x.profile ? [x.profile] : []))]);
6889
+ const answers = answersOf(profiles, [...(ctx.connectionProfiles ?? []), ...split.flatMap(x => (x.profile ? [x.profile] : []))],
6890
+ ctx.profileDefault);
6724
6891
  // What the lab would supply at submit, written down: no lab supplies it later.
6725
6892
  const out = clone(doc) ;
6726
6893
  out.evals = (Array.isArray(out.evals) ? out.evals : [])
@@ -6896,7 +7063,7 @@ function stagesFor(run , i , resolveFilters
6896
7063
  // without a resolver, a Library link reads nothing, a private one its own.
6897
7064
  return { text: targetStepOf(run, i, k) .prompt, kind, withImage: sendsImage(ch),
6898
7065
  verbatim: !!targetEntryOf(run, i, k)?.verbatim, before: beforeFilters(ch, resolveFilters),
6899
- modifiers: stageModifiers(ch, resolveFilters), settings };
7066
+ after: afterFilters(ch, resolveFilters), modifiers: stageModifiers(ch, resolveFilters), settings };
6900
7067
  });
6901
7068
  // Each named by the connection whose key it is sent with.
6902
7069
  const asked = (key , held ) => {
@@ -7178,8 +7345,10 @@ function validateEvals(ev , files
7178
7345
  continue;
7179
7346
  }
7180
7347
  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");
7348
+ // Nor does a call that was refused: there is no answer. Scored in
7349
+ // points, the other metrics are alternatives rather than more to hold
7350
+ // (#560: refused, or the reply says it can't read the file).
7351
+ if (scored.length > 1 && ev.scoring?.mode !== "weighted") say("expects its call refused, and states something the answer should hold too");
7183
7352
  continue;
7184
7353
  }
7185
7354
  const wants = scored.filter(m => !m.not && (m.type === "contains-all" || m.type === "contains-any"));
@@ -7304,12 +7473,12 @@ export {
7304
7473
  SOURCE_TYPES, DEFAULT_SOURCE_TYPE, sourceTypeOf, WORKFLOW_PLATFORMS, platformOf, DYNAMIC_PROVIDERS, providerOf, WIZARDS, wizardProgress,
7305
7474
  registerKinds, defaultKind, jobDefaults, applyModifiers, keyVar, slugOf, uniqueSlug, isSlug, withSlugs, SLUG_MAX, connectionOf, addressOf, connectionSettings,
7306
7475
  splitProfile, mergeAnswer, answerKey, answerOfKey, answersOf, runAnswer, answerConnectionId, connectionScoped,
7307
- connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, connectionImage, connectionProblems,
7476
+ connectionRequest, connectionBase, connectionSend, connectionReply, connectionReplyProblem, replyFinish, contentJoined, connectionImage, connectionProblems,
7308
7477
  CONNECTION_FIELDS, SETTING_KEYS, OPTION_FIELDS, jobLabel, scenarioLabel, jobWords, versionProblem,
7309
7478
  contentOf, withContent, replyOf,
7310
7479
  targetsOf, targetLetter, targetLabel, targetStepOf, targetEntryOf, jobTargetType, targetAnswerOf, targetAnswerKey, recordedTargetOf, withTarget,
7311
7480
  tokensOf, withTokens, sendsImage, withImage, flowStepOf, requestStepOf, readAsOf, outOf, withOut, slotEntry, SLOTS,
7312
- filterSetsOf, withFilterSets, stageModifiers, beforeFilters, gradedText,
7481
+ filterSetsOf, withFilterSets, stageModifiers, beforeFilters, afterFilters, gradedText,
7313
7482
  blankPipeline, upgradePipeline, fatProfileRef, newId, newEval, newLink, validatePipeline, resolvePipeline, pipelineOfRun, stagesFor, profileIds, answerKeys,
7314
7483
  scenarioProfile, lastKind, lastModifier, modifierSummary, itemScores, scenarioEvals, scenarioPasses, groupPass, overallVerdict, passVerdict, isSkipped, failedScore,
7315
7484
  evalLabel, evalsOf, evalsDataset, casesRef, groupOf, groupOfMetrics, pipelineWarnings, itemsFound, previewOf, isWholeRun, EVAL_FIELDS,
@@ -153,11 +153,12 @@ export function skeletonFor(step , reads ) {
153
153
  call of the step at that iteration: imported twice, it is skipped the
154
154
  second time, while another run's call -- the same request, sent again,
155
155
  answered afresh -- is its own record, since a flow that sends the same
156
- words every time is exactly where production's replies differ. One
157
- written by hand or uploaded is the same as another that read and sent the
158
- same things. */
156
+ words every time is exactly where production's replies differ. An edited
157
+ call keeps its run's key, so Import runs skips it too (#587). One written
158
+ by hand or uploaded is the same as another that read and sent the same
159
+ things. Mirrored by server.py's record_key. */
159
160
  export function recordKey(r ) {
160
- if (r.origin?.kind === "run" && r.origin.run) {
161
+ if (r.origin?.run) {
161
162
  return JSON.stringify({ step: r.step, run: r.origin.run, iteration: r.origin.iteration ?? "" });
162
163
  }
163
164
  const sorted = (v ) => Array.isArray(v) ? v.map(sorted)