evals-lab 0.15.0 → 0.16.1

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.
@@ -454,7 +454,7 @@ export const FILTER_TYPES = {
454
454
  },
455
455
  },
456
456
  "reject": {
457
- label: "Reject", about: "Discard the reply when it matches its conditions.", icon: "trash",
457
+ label: "Reject", about: "Discard the response when it matches its conditions.", icon: "trash",
458
458
  matches: "reply", conditions: ["blank", "regex", "itemCount", "share", "items"], rejects: true,
459
459
  apply: (filter, reply, on) => ({ body: reply.body, items: reply.items, dropped: [],
460
460
  reject: rejectFires(filter.match, reply.body, on === "text" ? oneItem(reply.body) : reply.items) ? filter.id : null }),
@@ -833,7 +833,7 @@ const LIST_MODIFIERS = {
833
833
 
834
834
  const LIST_KIND = {
835
835
  label: "List", noun: "items", offers: ["items"],
836
- description: "Splits the reply into items (by commas, lines or a JSON array) that evals and later jobs read one by one.",
836
+ description: "Splits the response into items (by commas, lines or a JSON array) that evals and later jobs read one by one.",
837
837
  settings: [
838
838
  { key: "parse", label: "Parse as", type: "select", choices: [
839
839
  { value: "csv", label: "CSV" }, { value: "lines", label: "One a line" }, { value: "json", label: "JSON array" }] },
@@ -37,7 +37,7 @@ export function atPath(v , path ) {
37
37
 
38
38
  /** Where [v] breaks [schema] -- the parts of JSON Schema a Parse JSON uses --
39
39
  or null where it holds. */
40
- export function schemaProblem(v , schema , at = "the reply") {
40
+ export function schemaProblem(v , schema , at = "the response") {
41
41
  if (!schema || typeof schema !== "object") return null;
42
42
  const s = schema ;
43
43
  const type = s.type;
@@ -122,7 +122,7 @@ const spoken = (g ) => (g.length === 1 ? g[0] : g);
122
122
 
123
123
  /** The recorded reply, or the reason a metric that compares with it cannot. */
124
124
  const recorded = (input ) => {
125
- if (input.recorded == null) throw new Error("this item has no recorded reply to compare with");
125
+ if (input.recorded == null) throw new Error("this item has no recorded response to compare with");
126
126
  return input.recorded;
127
127
  };
128
128
 
@@ -132,7 +132,7 @@ const recorded = (input ) => {
132
132
  function verdictOf(said ) {
133
133
  const m = /\{[\s\S]*\}/.exec(said);
134
134
  const read = m ? json(m[0]) : { error: "no JSON" };
135
- if (read.error || !read.value || typeof read.value !== "object") throw new Error(`the grader's reply could not be read: ${short(said)}`);
135
+ if (read.error || !read.value || typeof read.value !== "object") throw new Error(`the grader's response could not be read: ${short(said)}`);
136
136
  return read.value ;
137
137
  }
138
138
  /** The grader's verdict on [prompt], its reply held to [schema] where its API
@@ -203,13 +203,13 @@ const caseSaid = (m ) => (m.ignoreCase === true ? "" : "matching
203
203
  const phrase = (...parts ) => parts.filter(Boolean).join(", ");
204
204
 
205
205
  // The families the Add metric picker lists the metrics under: what each reads.
206
- const TEXT = "The reply's text", RESULT = "The result", RECORDED = "Recorded reply", GRADED = "Model-graded";
206
+ const TEXT = "The response's text", RESULT = "The result", RECORDED = "Recorded response", GRADED = "Model-graded";
207
207
 
208
208
  const metrics = {
209
209
  equals: {
210
210
  family: TEXT,
211
211
  label: "Equals",
212
- description: "Passes when the reply is exactly the value; as JSON, the same value with keys in any order.",
212
+ description: "Passes when the response is exactly the value; as JSON, the same value with keys in any order.",
213
213
  // As JSON, a reply equals the value as a value -- keys in any order;
214
214
  // otherwise, as the text, once trimmed.
215
215
  options: [{ key: "value", label: "Value", type: "textarea" }, { key: "json", label: "As JSON", type: "checkbox" }],
@@ -225,7 +225,7 @@ const metrics = {
225
225
  contains: {
226
226
  family: TEXT,
227
227
  label: "Contains",
228
- description: "Passes when the reply contains the value; turned round, an exception excuses the value inside it.",
228
+ description: "Passes when the response contains the value; turned round, an exception excuses the value inside it.",
229
229
  options: [{ key: "value", label: "Value", type: "text" }, { key: "ignoreCase", label: "Ignore case", type: "checkbox" },
230
230
  { key: "except", label: "Except", type: "textarea" }],
231
231
  defaults: () => ({ value: "", ignoreCase: true }),
@@ -243,7 +243,7 @@ const metrics = {
243
243
  "contains-any": {
244
244
  family: TEXT,
245
245
  label: "Contains any",
246
- description: "Passes when the reply contains at least one of the values, one a line.",
246
+ description: "Passes when the response contains at least one of the values, one a line.",
247
247
  options: [{ key: "values", label: "Values", type: "textarea" }, { key: "ignoreCase", label: "Ignore case", type: "checkbox" }],
248
248
  defaults: () => ({ values: "", ignoreCase: true }),
249
249
  says: (m) => phrase(quotedAll(lines(m.values)), caseSaid(m)),
@@ -258,7 +258,7 @@ const metrics = {
258
258
  "contains-all": {
259
259
  family: TEXT,
260
260
  label: "Contains all",
261
- description: "Passes when the reply contains every value, one a line.",
261
+ description: "Passes when the response contains every value, one a line.",
262
262
  options: [{ key: "values", label: "Values", type: "textarea" }, { key: "ignoreCase", label: "Ignore case", type: "checkbox" }],
263
263
  defaults: () => ({ values: "", ignoreCase: true }),
264
264
  says: (m) => phrase(quotedAll(lines(m.values), "and"), caseSaid(m)),
@@ -274,7 +274,7 @@ const metrics = {
274
274
  regex: {
275
275
  family: TEXT,
276
276
  label: "Matches",
277
- description: "Passes when the reply matches the regular expression.",
277
+ description: "Passes when the response matches the regular expression.",
278
278
  options: [{ key: "pattern", label: "Pattern", type: "text" }, { key: "flags", label: "Flags", type: "text" }],
279
279
  defaults: () => ({ pattern: "", flags: "" }),
280
280
  says: (m) => `/${str(m.pattern)}/${str(m.flags)}`,
@@ -287,7 +287,7 @@ const metrics = {
287
287
  "starts-with": {
288
288
  family: TEXT,
289
289
  label: "Starts with",
290
- description: "Passes when the reply starts with the value.",
290
+ description: "Passes when the response starts with the value.",
291
291
  options: [{ key: "value", label: "Value", type: "text" }],
292
292
  defaults: () => ({ value: "" }),
293
293
  says: (m) => quoted(str(m.value)),
@@ -300,7 +300,7 @@ const metrics = {
300
300
  "is-json": {
301
301
  family: TEXT,
302
302
  label: "Is JSON",
303
- description: "Passes when the reply is JSON and, with a schema, holds to it.",
303
+ description: "Passes when the response is JSON and, with a schema, holds to it.",
304
304
  options: [{ key: "schema", label: "Schema", type: "textarea" }],
305
305
  defaults: () => ({ schema: "" }),
306
306
  validate: (m, at, bad) => { if (str(m.schema).trim() && json(str(m.schema)).error) bad.push(`${at}: the schema is not JSON`); },
@@ -315,7 +315,7 @@ const metrics = {
315
315
  "json-field": {
316
316
  family: TEXT,
317
317
  label: "JSON field",
318
- description: "Reads one field of a JSON reply and compares it.",
318
+ description: "Reads one field of a JSON response and compares it.",
319
319
  options: [
320
320
  { key: "path", label: "Field", type: "text" },
321
321
  { key: "op", label: "Is", type: "select", choices: [
@@ -348,7 +348,7 @@ const metrics = {
348
348
  family: RESULT,
349
349
  readsResult: true,
350
350
  label: "Discarded",
351
- description: "Passes only when the job's rules discarded the reply.",
351
+ description: "Passes only when the job's rules discarded the response.",
352
352
  options: [],
353
353
  defaults: () => ({}),
354
354
  score: (input) => {
@@ -364,7 +364,7 @@ const metrics = {
364
364
  readsResult: true,
365
365
  readsRefusal: true,
366
366
  label: "Refused",
367
- description: "Passes only when the target's call was refused: an error from the connection, not a reply.",
367
+ description: "Passes only when the variant's call was refused: an error from the connection, not a response.",
368
368
  options: [],
369
369
  defaults: () => ({}),
370
370
  score: (input) => {
@@ -413,7 +413,7 @@ const metrics = {
413
413
  family: RESULT,
414
414
  readsResult: true,
415
415
  label: "Latency",
416
- description: "Passes when the reply came back within the time set.",
416
+ description: "Passes when the response came back within the time set.",
417
417
  options: [{ key: "max", label: "At most (ms)", type: "number" }],
418
418
  defaults: () => ({ max: 5000 }),
419
419
  // Unset, it is scored against 0 ms, which validate refuses: no number to say.
@@ -424,7 +424,7 @@ const metrics = {
424
424
  levenshtein: {
425
425
  family: TEXT,
426
426
  label: "Near",
427
- description: "Passes when the reply is within a few edits of the value.",
427
+ description: "Passes when the response is within a few edits of the value.",
428
428
  options: [{ key: "value", label: "Value", type: "textarea" }, { key: "max", label: "Edits at most", type: "number" }],
429
429
  defaults: () => ({ value: "", max: 5 }),
430
430
  says: (m) => `within ${num(m.max) ?? 0} ${num(m.max) === 1 ? "edit" : "edits"} of ${quoted(str(m.value).trim())}`,
@@ -442,7 +442,7 @@ const metrics = {
442
442
  family: RECORDED,
443
443
  parsedOnly: true,
444
444
  label: "Same as recorded",
445
- description: "Passes when the reply is what production recorded for the same item.",
445
+ description: "Passes when the response is what production recorded for the same item.",
446
446
  options: [],
447
447
  defaults: () => ({}),
448
448
  score: (input, _m, ctx) => {
@@ -457,7 +457,7 @@ const metrics = {
457
457
  family: RECORDED,
458
458
  parsedOnly: true,
459
459
  label: "Fields as recorded",
460
- description: "Passes when the fields listed are what the recorded reply had.",
460
+ description: "Passes when the fields listed are what the recorded response had.",
461
461
  options: [{ key: "fields", label: "Fields", type: "textarea" }],
462
462
  defaults: () => ({ fields: "" }),
463
463
  says: (m) => lines(m.fields).join(", "),
@@ -466,7 +466,7 @@ const metrics = {
466
466
  if (ctx.recordedTarget) return null;
467
467
  const a = json(input.text), b = json(recorded(input));
468
468
  if (a.error) return ok(false, `not JSON: ${a.error}`);
469
- if (b.error) return ok(false, "the recorded reply was not JSON");
469
+ if (b.error) return ok(false, "the recorded response was not JSON");
470
470
  const fields = lines(m.fields);
471
471
  const differ = fields.filter((f) => !same(atPath(a.value, f), atPath(b.value, f)));
472
472
  return ok(!differ.length, differ.length ? `${differ.join(", ")} differ from recorded` : "the fields match recorded",
@@ -477,7 +477,7 @@ const metrics = {
477
477
  family: RECORDED,
478
478
  parsedOnly: true,
479
479
  label: "Parses as recorded",
480
- description: "Passes when the reply parses as JSON exactly when the recorded reply did.",
480
+ description: "Passes when the response parses as JSON exactly when the recorded response did.",
481
481
  options: [],
482
482
  defaults: () => ({}),
483
483
  score: (input, _m, ctx) => {
@@ -490,7 +490,7 @@ const metrics = {
490
490
  "llm-rubric": {
491
491
  family: GRADED,
492
492
  label: "Rubric",
493
- description: "Asks the grader whether the reply meets the rubric.",
493
+ description: "Asks the grader whether the response meets the rubric.",
494
494
  graded: true,
495
495
  parsedOnly: true,
496
496
  options: [{ key: "rubric", label: "Rubric", type: "textarea" }, { key: "threshold", label: "Expected: score at least", type: "number" }],
@@ -504,12 +504,12 @@ const metrics = {
504
504
  factuality: {
505
505
  family: GRADED,
506
506
  label: "Factual",
507
- description: "Asks the grader whether the reply agrees with the reference, or the recorded reply.",
507
+ description: "Asks the grader whether the response agrees with the reference, or the recorded response.",
508
508
  graded: true,
509
509
  parsedOnly: true,
510
510
  options: [{ key: "reference", label: "Reference", type: "textarea" }, { key: "threshold", label: "Expected: score at least", type: "number" }],
511
511
  defaults: () => ({ reference: "", threshold: 0.5 }),
512
- says: (m) => phrase(str(m.reference).trim() ? `with ${quoted(str(m.reference).replace(/\s+/g, " ").trim())}` : "with the recorded reply",
512
+ says: (m) => phrase(str(m.reference).trim() ? `with ${quoted(str(m.reference).replace(/\s+/g, " ").trim())}` : "with the recorded response",
513
513
  num(m.threshold) != null && num(m.threshold) !== 0.5 ? `score at least ${num(m.threshold)}` : ""),
514
514
  // A blank reference is the recorded reply.
515
515
  score: async (input, m, ctx) => gradedPass(await graded(ctx,
@@ -520,7 +520,7 @@ const metrics = {
520
520
  "judge-vs-production": {
521
521
  family: GRADED,
522
522
  label: "Judged against recorded",
523
- description: "Asks the grader whether the reply is better than, the same as or worse than the recorded one.",
523
+ description: "Asks the grader whether the response is better than, the same as or worse than the recorded one.",
524
524
  graded: true,
525
525
  parsedOnly: true,
526
526
  expects: true,
package/lab/run-evals.js CHANGED
@@ -628,7 +628,7 @@ function callsFor(connections, links, text, plan, record) {
628
628
  // one, and the loop job 1's flow step sits in.
629
629
  const tokenRecord = (run, record, text) => {
630
630
  const r = record ?? (text != null ? textRecord(text) : null);
631
- return r && { scope: r.scope || {}, at: r.at ?? null, loop: core.flowStepOf(run.jobs[0])?.loop ?? null };
631
+ return r && { scope: r.scope || {}, at: r.at ?? null, loop: core.flowStepOf(core.jobsOf(run)[0])?.loop ?? null };
632
632
  };
633
633
 
634
634
  // A prompt as a report restates it: an expression token reads an item's
@@ -768,27 +768,6 @@ function readUtf8(file) {
768
768
  const outcomesOf = (run, items, settled) =>
769
769
  core.targetsOf(run).map((_, i) => core.scenarioEvals(run, i, items, settled));
770
770
 
771
- // A link's Whole run verdict, per Target, keyed by the eval's id: a Library
772
- // group's `run` metrics read over the replies so far (readGroupRun). A private
773
- // group's Whole run is scenarioEvals' own (it holds the body); a link's body
774
- // is held apart, in --groups, so reading it beside the link's items is the
775
- // runner's (§17). [resolve] gives a link's body by its reference.
776
- function wholeRunsOf(run, items, resolve) {
777
- const kind = core.lastKind(run);
778
- return core.targetsOf(run).map((_, i) => {
779
- const out = {};
780
- const ress = (items || []).map(it => (it && !it.unrun ? it.scenarios?.[i]?.res : undefined));
781
- for (const t of core.evalsOf(run)) {
782
- // A private whole-run group is already scenarioEvals' verdict; here is
783
- // the run beside a link's (or private group's) own items.
784
- if (t.type !== "group" || core.isWholeRun(core.EVAL_TYPES.group, t)) continue;
785
- const body = core.groupOf(t, { group: resolve });
786
- if (body && Array.isArray(body.run) && body.run.length) out[t.id] = core.readGroupRun(body, ress, kind);
787
- }
788
- return out;
789
- });
790
- }
791
-
792
771
  /**
793
772
  * One eval's verdict on one target, for the build: its own rule -- every
794
773
  * item it read passed, or a whole-run eval's verdict -- with [minPass] the
@@ -818,7 +797,7 @@ function evalVerdict(o, settled, minPass, wholeRun) {
818
797
  // Each target's evals, keyed by the eval's id: a whole-run eval's verdict, a
819
798
  // per-item eval's counts -- and, where a link reads a Whole run beside its
820
799
  // items, that verdict too -- with the build's verdict on each. [wholes] is
821
- // wholeRunsOf, one map per target.
800
+ // core.wholeRunsOf, one map per target.
822
801
  const verdictsOf = (outcomes, settled, minPass, wholes = []) => outcomes.map((evals, i) =>
823
802
  Object.fromEntries(evals.map(o => {
824
803
  const wr = wholes[i] && wholes[i][o.id];
@@ -911,32 +890,30 @@ const scenariosOf = run => core.targetsOf(run).map((sc, i) => {
911
890
  async function runSnapshot(o) {
912
891
  const gctx = gradingContext(o);
913
892
  const run = readRun(o.run, gctx);
914
- // One item a pipeline holds itself (Text, or Prompt only's bare one), or
915
- // the Source's files.
916
- const content = core.contentOf(run);
917
- const inline = core.CONTENT_TYPES[content.type];
918
- const bare = !!inline?.bare;
919
- const text = inline?.inline && !bare ? inline.text?.(content, {}) ?? "" : "";
920
- const files = content.type === "source" ? content.files || [] : [];
921
- if (!bare && !files.length && !text.trim()) {
922
- broken(`${o.run} names no files and no text, so there is nothing to run`);
923
- }
893
+ // Each Get items step's items in turn: a Source's files, a text the
894
+ // pipeline holds itself, or Prompt only's bare one.
895
+ const listed = core.runItemsOf(run);
896
+ if (!listed.length) broken(`${o.run} names no files and no text, so there is nothing to run`);
897
+ // A row keeps the Get items step it came from when there are several, so
898
+ // Results groups its rows by Source; a run over one reads as it always did.
899
+ const several = core.itemContents(run).length > 1;
924
900
 
925
901
  // The one group a single-group run names, for an eval type of its own that
926
902
  // reads a case; a `group` eval reads each group's case by the item's name.
927
903
  const graded = gradedBy(gctx.primary());
928
904
 
929
- const items = bare ? [{ name: null, kind: "text", text: null, bare: true }]
930
- : text.trim() ? [{ name: null, kind: "text", text }]
931
- : files.map(name => {
932
- const ext = path.extname(name).toLowerCase();
933
- if (RUN_IMAGE_TYPES.has(ext)) return { name, kind: "image" };
934
- if (core.documentTypeOf(name)) return { name, kind: "document" };
935
- // A record (flows/record.ts): one call of a Power Automate step, what
936
- // an HTTP Request is rebuilt from.
937
- if (ext === ".json") return { name, kind: "record" };
938
- return { name, kind: "text", unmapped: !RUN_TEXT_TYPES.has(ext) };
939
- });
905
+ const items = listed.map(({ name, text, bare, dir, step }) => {
906
+ const at = { dir, ...(several ? { step } : {}) };
907
+ if (bare) return { name: null, kind: "text", text: null, bare: true, ...at };
908
+ if (name == null) return { name: null, kind: "text", text, ...at };
909
+ const ext = path.extname(name).toLowerCase();
910
+ if (RUN_IMAGE_TYPES.has(ext)) return { name, kind: "image", ...at };
911
+ if (core.documentTypeOf(name)) return { name, kind: "document", ...at };
912
+ // A record (flows/record.ts): one call of a Power Automate step, what
913
+ // an HTTP Request is rebuilt from.
914
+ if (ext === ".json") return { name, kind: "record", ...at };
915
+ return { name, kind: "text", unmapped: !RUN_TEXT_TYPES.has(ext), ...at };
916
+ });
940
917
  const total = items.length;
941
918
  // Only the run's own image items need ImageMagick: a text-only Source runs
942
919
  // with nothing on PATH but the node running this.
@@ -987,8 +964,9 @@ async function runSnapshot(o) {
987
964
  let failed = null;
988
965
  for (const plan of plans) {
989
966
  let dataUrl = null;
967
+ // A later Get items' files are kept under its own folder (itemContents).
968
+ const file = item.name != null ? path.join(o.source, item.dir, item.name) : null;
990
969
  if (item.kind === "image") {
991
- const file = path.join(o.source, item.name);
992
970
  if (!fs.existsSync(file)) { failed = `the file ${item.name} is not in the source`; break; }
993
971
  try {
994
972
  ({ dataUrl } = prepare(bin, file, plan.connections[0]));
@@ -998,13 +976,11 @@ async function runSnapshot(o) {
998
976
  }
999
977
  }
1000
978
  if (item.kind === "document") {
1001
- const file = path.join(o.source, item.name);
1002
979
  if (!fs.existsSync(file)) { failed = `the file ${item.name} is not in the source`; break; }
1003
980
  dataUrl = documentOf(file);
1004
981
  }
1005
982
  let record = null;
1006
983
  if (item.kind === "record") {
1007
- const file = path.join(o.source, item.name);
1008
984
  try { record = JSON.parse(readUtf8(file)); } catch (e) {
1009
985
  failed = fs.existsSync(file) ? `${item.name} is not a record -- ${e.message}` : `the file ${item.name} is not in the source`;
1010
986
  break;
@@ -1012,12 +988,12 @@ async function runSnapshot(o) {
1012
988
  }
1013
989
  // A text file the Source does not hold is unrun, as an image is: the
1014
990
  // run is incomplete rather than broken.
1015
- if (item.kind === "text" && !item.bare && item.text == null && !fs.existsSync(path.join(o.source, item.name))) {
991
+ if (item.kind === "text" && !item.bare && item.text == null && !fs.existsSync(file)) {
1016
992
  failed = `the file ${item.name} is not in the source`;
1017
993
  break;
1018
994
  }
1019
995
  const textOf = item.kind === "text" && !item.bare
1020
- ? (item.text != null ? item.text : readUtf8(path.join(o.source, item.name))) : null;
996
+ ? (item.text != null ? item.text : readUtf8(file)) : null;
1021
997
  const res = await core.runPipeline(plan.stages, dataUrl, callsFor(plan.connections, plan.links, textOf, plan, record),
1022
998
  { tokens: plan.tokens, text: textOf, record: tokenRecord(run, record, textOf) });
1023
999
  // A case is found by its file's name, whatever kind of file it is: a
@@ -1029,7 +1005,8 @@ async function runSnapshot(o) {
1029
1005
  }
1030
1006
  // Production's reply to the item, where its record holds one: what a
1031
1007
  // metric compares with, kept so a re-score reads it again.
1032
- const out = { item: i, name: item.name, kind: item.kind, ...(production != null ? { production } : {}),
1008
+ const out = { item: i, name: item.name, kind: item.kind, ...(item.step ? { getItems: item.step } : {}),
1009
+ ...(production != null ? { production } : {}),
1033
1010
  unmapped: item.unmapped || null, ms: Date.now() - t0,
1034
1011
  scenarios: sides, ...(failed ? { unrun: failed } : {}) };
1035
1012
  results[i] = out;
@@ -1044,7 +1021,7 @@ async function runSnapshot(o) {
1044
1021
  const complete = !cancelled && !unrun.length
1045
1022
  && ranItems.length === total && ranItems.every(it => it && !it.unrun);
1046
1023
  const outcomes = outcomesOf(run, ranItems, complete);
1047
- const wholes = wholeRunsOf(run, ranItems, gctx.resolve);
1024
+ const wholes = core.wholeRunsOf(run, ranItems, gctx.resolve);
1048
1025
  const verdicts = verdictsOf(outcomes, complete, o.minPass, wholes);
1049
1026
  const report = {
1050
1027
  runner: RUNNER,
@@ -1139,7 +1116,7 @@ async function runRescore(o){
1139
1116
  // A stored run that stopped short re-scores as what it is: incomplete.
1140
1117
  const complete = items.every(it => isObject(it) && !it.unrun);
1141
1118
  const outcomes = outcomesOf(run, items, complete);
1142
- const wholes = wholeRunsOf(run, items, gctx.resolve);
1119
+ const wholes = core.wholeRunsOf(run, items, gctx.resolve);
1143
1120
  const verdicts = verdictsOf(outcomes, complete, o.minPass, wholes);
1144
1121
  const report = {
1145
1122
  runner: RUNNER,
@@ -1172,17 +1149,18 @@ function cliRun(o, dataset) {
1172
1149
  // Graded, so the job answers with a list: the configuration that reads a
1173
1150
  // reply as this command always has (legacyTagsOut), under the dataset's
1174
1151
  // rules. A --pipeline states its own.
1175
- doc.jobs[0] = core.withOut(doc.jobs[0], core.legacyTagsOut(rulesOf(dataset), "LOWER"));
1152
+ let job = core.withOut(core.jobsOf(doc)[0], core.legacyTagsOut(rulesOf(dataset), "LOWER"));
1176
1153
  if (o.tokens) {
1177
1154
  try {
1178
- doc.jobs[0] = core.withTokens(doc.jobs[0], JSON.parse(fs.readFileSync(o.tokens, "utf8")));
1155
+ job = core.withTokens(job, JSON.parse(fs.readFileSync(o.tokens, "utf8")));
1179
1156
  } catch (e) {
1180
1157
  broken(`${o.tokens}: ${e.message}`);
1181
1158
  }
1182
1159
  }
1160
+ doc = core.withJobs(doc, [job]);
1183
1161
  doc = core.withContent(doc, { type: "source", ref: { id: "cli", name: "" }, first: null, loops: 1, files: [] });
1184
- doc.targets = [{ id: "cli", name: "", connection: { id: "cli", name: "" }, profile: null,
1185
- steps: [{ type: "prompt", prompt: o.prompt ?? "" }] }];
1162
+ doc = core.withTargets(doc, [{ id: "cli", name: "", connection: { id: "cli", name: "" }, profile: null,
1163
+ steps: [{ type: "prompt", prompt: o.prompt ?? "" }] }]);
1186
1164
  doc.evals = [{ id: "cli", type: "group", name: "", continueOnFailure: true, group: { id: "cli", name: "" }, pin: null }];
1187
1165
  doc.profiles = { [core.answerKey("cli")]: { name: null, url: o.url, model: o.model, type: "openai-compatible" } };
1188
1166
  return doc;