@toloka-platform/sdk 0.12.0 → 0.12.2

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 (38) hide show
  1. package/dist/cli/toloka.js +144 -141
  2. package/dist/cli/toloka.js.jsc +0 -0
  3. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  4. package/dist/cli/toloka.js.sha256 +1 -1
  5. package/docs/cli.md +11 -8
  6. package/docs/datasets.md +17 -5
  7. package/openapi/gen/api.ts +25 -0
  8. package/openapi/gen/models/createDatasetFieldsFromUploadedRowsBody.ts +4 -0
  9. package/openapi/gen/models/datasetExportReportTokensEstimatorVersion.ts +1 -1
  10. package/openapi/gen/models/datasetField.ts +2 -0
  11. package/openapi/gen/models/datasetFieldCopyOperation.ts +7 -0
  12. package/openapi/gen/models/datasetFieldOperation.ts +4 -0
  13. package/openapi/gen/models/datasetFieldSplitFillOperation.ts +5 -0
  14. package/openapi/gen/models/index.ts +4 -0
  15. package/openapi/gen/models/trainingCostEstimateOneOfInputsTrainingReportAnyOfTokensEstimatorVersion.ts +1 -1
  16. package/openapi/gen/models/trainingCostEstimateOneOfInputsValidationAnyOfReportAnyOfTokensEstimatorVersion.ts +1 -1
  17. package/openapi/gen/models/trainingCostEstimateOneOfTwosevenInputsTrainingReportAnyOfTokensEstimatorVersion.ts +1 -1
  18. package/openapi/gen/models/trainingCostEstimateOneOfTwosevenInputsValidationAnyOfReportAnyOfTokensEstimatorVersion.ts +1 -1
  19. package/openapi/gen/models/trainingRunInputsAnyOfTrainingReportTokensEstimatorVersion.ts +1 -1
  20. package/openapi/gen/models/trainingRunInputsAnyOfValidationAnyOfReportTokensEstimatorVersion.ts +1 -1
  21. package/openapi/gen/models/trainingRunQuoteInputsTrainingReportAnyOfTokensEstimatorVersion.ts +1 -1
  22. package/openapi/gen/models/trainingRunQuoteInputsValidationAnyOfReportAnyOfTokensEstimatorVersion.ts +1 -1
  23. package/package.json +2 -2
  24. package/skills/catalog.json +8 -8
  25. package/skills/toloka-build/references/workflow-and-cli.md +14 -5
  26. package/skills/toloka-fine-tuning/SKILL.md +5 -4
  27. package/skills/toloka-gisting/SKILL.md +6 -5
  28. package/src/cli/commands/dataset.ts +45 -3
  29. package/src/cli/context.ts +16 -10
  30. package/src/cli/data.ts +45 -17
  31. package/src/cli/fields.ts +80 -0
  32. package/src/cli/filters.ts +15 -6
  33. package/src/cli/slices.ts +16 -2
  34. package/src/cli/splits.ts +58 -3
  35. package/src/cli/trainings.ts +31 -7
  36. package/src/cli/uploaded-fields.ts +20 -0
  37. package/src/client.ts +38 -1
  38. package/src/index.ts +1 -0
@@ -3,6 +3,7 @@ import type { Command } from "commander";
3
3
  import {
4
4
  collectValues,
5
5
  columnList,
6
+ fieldList,
6
7
  positiveInteger,
7
8
  resolveContext,
8
9
  splitParts,
@@ -18,6 +19,7 @@ import {
18
19
  loadDataset,
19
20
  uploadDataset,
20
21
  } from "../data";
22
+ import { addDatasetFields } from "../fields";
21
23
  import { assertProjectSelected } from "../project-context";
22
24
  import { createDatasetSlice, listDatasetSlices, showDatasetSlice } from "../slices";
23
25
  import { listDatasetColumns, splitDataset, type SplitRatios } from "../splits";
@@ -46,8 +48,11 @@ export const registerDatasetCommands = (program: Command, host: CommandHost): vo
46
48
  const result = await createDataset(client, { projectId, name });
47
49
  context.io.stdout(result.output);
48
50
  });
49
- dataset
51
+ const fields = dataset
50
52
  .command("fields")
53
+ .description("print field declarations inferred from uploaded rows, or add dataset fields from them");
54
+ fields
55
+ .command("declarations", { isDefault: true })
51
56
  .argument("<name>", "dataset name or id")
52
57
  .description("print field declarations inferred from uploaded rows")
53
58
  .action(async (name: string) => {
@@ -59,20 +64,57 @@ export const registerDatasetCommands = (program: Command, host: CommandHost): vo
59
64
  });
60
65
  context.io.stdout(result.output);
61
66
  });
67
+ fields
68
+ .command("add")
69
+ .argument("<name>", "dataset name or id")
70
+ .argument("[fields...]", "top-level fields of the uploaded rows to add as dataset fields")
71
+ .option("--all", "add every top-level field of the uploaded rows that is not a dataset field yet", false)
72
+ .option("--no-wait", "return as soon as the fields are added, while their values are still being copied")
73
+ .option("--timeout <ms>", "how long to wait for the values to be copied", positiveInteger)
74
+ .option("--json", "print JSON", false)
75
+ .description(
76
+ "add dataset fields from fields of the uploaded rows, so filters, --balance-by and split columns can use their values; uploads after this fill them too",
77
+ )
78
+ .action(
79
+ async (
80
+ name: string,
81
+ names: string[],
82
+ options: { all: boolean; wait: boolean; timeout?: number; json: boolean },
83
+ ) => {
84
+ const { sourceRoot, client, projectId } = await resolveContext(globals(), context);
85
+ const result = await addDatasetFields(client, {
86
+ sourceRoot,
87
+ datasetName: name,
88
+ fields: names,
89
+ all: options.all,
90
+ wait: options.wait,
91
+ json: options.json,
92
+ ...(projectId === undefined ? {} : { projectId }),
93
+ ...(options.timeout === undefined ? {} : { waitFor: { timeoutMs: options.timeout } }),
94
+ });
95
+ context.io.stdout(result.output);
96
+ },
97
+ );
62
98
  dataset
63
99
  .command("load")
64
100
  .argument("<name>", "dataset name or id")
65
101
  .requiredOption("--out <file>", "JSONL file to write")
102
+ .option(
103
+ "--fields <fields>",
104
+ "comma-separated dataset fields to write (default: every field); uploaded holds each uploaded row whole, as one value",
105
+ fieldList,
106
+ )
66
107
  .option("--limit <n>", "how many rows to keep after download", positiveInteger)
67
108
  .option("--timeout <ms>", "how long to wait for the export", positiveInteger)
68
- .description("download live dataset rows into a JSONL file")
69
- .action(async (name: string, options: { out: string; limit?: number; timeout?: number }) => {
109
+ .description("download live dataset rows into a JSONL file, one object of the chosen fields per row")
110
+ .action(async (name: string, options: { out: string; fields?: string[]; limit?: number; timeout?: number }) => {
70
111
  const { sourceRoot, client, projectId } = await resolveContext(globals(), context);
71
112
  const result = await loadDataset(client, {
72
113
  sourceRoot,
73
114
  datasetName: name,
74
115
  outPath: resolve(options.out),
75
116
  ...(projectId === undefined ? {} : { projectId }),
117
+ ...(options.fields === undefined ? {} : { fields: options.fields }),
76
118
  ...(options.limit === undefined ? {} : { limit: options.limit }),
77
119
  ...(options.timeout === undefined ? {} : { timeoutMs: options.timeout }),
78
120
  });
@@ -145,16 +145,22 @@ export const splitRatios = (raw: string): { train: number; validate: number; tes
145
145
 
146
146
  export const collectValues = (value: string, previous: readonly string[] = []): string[] => [...previous, value];
147
147
 
148
- export const columnList = (raw: string): string[] => {
149
- const columns = raw
150
- .split(",")
151
- .map((column) => column.trim())
152
- .filter((column) => column.length > 0);
153
- if (columns.length === 0) {
154
- throw new InvalidArgumentError("Expected one or more column names separated by commas, for example text,intent.");
155
- }
156
- return columns;
157
- };
148
+ const nameList =
149
+ (noun: string) =>
150
+ (raw: string): string[] => {
151
+ const names = raw
152
+ .split(",")
153
+ .map((name) => name.trim())
154
+ .filter((name) => name.length > 0);
155
+ if (names.length === 0) {
156
+ throw new InvalidArgumentError(`Expected one or more ${noun} names separated by commas, for example text,intent.`);
157
+ }
158
+ return names;
159
+ };
160
+
161
+ export const columnList = nameList("column");
162
+
163
+ export const fieldList = nameList("field");
158
164
 
159
165
  const SPLIT_PART_ROLES = ["train", "validate", "test"] as const;
160
166
 
package/src/cli/data.ts CHANGED
@@ -92,6 +92,7 @@ export type LoadDatasetOptions = {
92
92
  readonly datasetName: string;
93
93
  readonly outPath: string;
94
94
  readonly projectId?: string;
95
+ readonly fields?: readonly string[];
95
96
  readonly limit?: number;
96
97
  readonly timeoutMs?: number;
97
98
  readonly fetch?: typeof globalThis.fetch;
@@ -106,7 +107,7 @@ type DatasetRef = {
106
107
  const isJsonObject = (value: unknown): value is Record<string, unknown> =>
107
108
  typeof value === "object" && value !== null && !Array.isArray(value);
108
109
 
109
- const summarizeJsonSchema = (schema: unknown): string => {
110
+ export const summarizeJsonSchema = (schema: unknown): string => {
110
111
  if (!isJsonObject(schema)) return "unknown";
111
112
  if (Array.isArray(schema.anyOf)) {
112
113
  const variants = [...new Set(schema.anyOf.map(summarizeJsonSchema))];
@@ -156,6 +157,13 @@ const declaredDatasetFields = async (options: {
156
157
 
157
158
  const formatDatasetChoice = (dataset: Dataset): string => `${dataset.name} (${dataset.id})`;
158
159
 
160
+ const MAX_LISTED_NAMES = 10;
161
+
162
+ export const listedNames = (names: readonly string[]): string =>
163
+ names.length <= MAX_LISTED_NAMES
164
+ ? names.join(", ")
165
+ : `${names.slice(0, MAX_LISTED_NAMES).join(", ")} and ${names.length - MAX_LISTED_NAMES} more`;
166
+
159
167
  const collectLiveDatasets = async (client: PotatoClient, projectId: string): Promise<Dataset[]> => {
160
168
  const datasets: Dataset[] = [];
161
169
  for await (const dataset of client.datasets.listAll({ projectId })) datasets.push(dataset);
@@ -210,8 +218,10 @@ export const resolveLiveDataset = async (
210
218
  createWhenMissing && datasetId === undefined,
211
219
  live.length === 0
212
220
  ? `Dataset "${token}" does not exist in project ${projectId}. Run toloka dataset create ${JSON.stringify(token)} first, or toloka apply for a dataset a pipeline declares.`
213
- : `Dataset "${token}" does not exist in project ${projectId}. This project has: ${live.map(formatDatasetChoice).join(", ")}.`,
214
- { hint: { disabled: true } },
221
+ : `Dataset "${token}" does not exist in project ${projectId}. This project has: ${listedNames(live.map(formatDatasetChoice))}.`,
222
+ live.length <= MAX_LISTED_NAMES
223
+ ? { hint: { disabled: true } }
224
+ : { hint: { description: "Run toloka dataset list to see every dataset of the project." } },
215
225
  );
216
226
 
217
227
  assertCli(
@@ -440,22 +450,35 @@ export async function uploadDataset(
440
450
  const uploadedOutput = `Uploaded ${completed.result.itemsIngested} rows to dataset ${resolved.name}.`;
441
451
  let inferredFields: ReadonlyArray<InferredField> = [];
442
452
  let inferenceReport: string | undefined;
443
- let warning: string | undefined;
453
+ const warnings: string[] = [];
444
454
  try {
445
455
  const inferred = await client.datasets.getInferredFields({ datasetId });
446
456
  inferredFields = inferred.fields;
447
457
  inferenceReport = formatInferredFields(inferredFields);
448
458
  } catch (error) {
449
459
  const reason = error instanceof Error ? error.message : String(error);
450
- warning = `Upload succeeded, but inferred fields could not be fetched: ${reason}`;
460
+ warnings.push(`Upload succeeded, but inferred fields could not be fetched: ${reason}`);
461
+ }
462
+ if (fields.length > 0) {
463
+ try {
464
+ await client.waitFor({ type: "datasetFields", datasetId }, options.waitFor);
465
+ } catch (error) {
466
+ if (!(error instanceof PotatoApiError && error.code === "wait_timeout")) throw error;
467
+ warnings.push(
468
+ `Upload succeeded, but its rows are still being copied into the dataset fields; check them with toloka dataset columns ${fieldsHint} --json.`,
469
+ );
470
+ }
451
471
  }
472
+ const addFieldsHint =
473
+ fields.length === 0 && inferredFields.some((field) => field.depth === 1)
474
+ ? `To filter, balance or split by these fields, add them as dataset fields: toloka dataset fields add ${fieldsHint} <field>… (or --all).`
475
+ : undefined;
452
476
  return {
453
477
  datasetId,
454
478
  rowsUploaded: completed.result.itemsIngested,
455
479
  inferredFields,
456
- output:
457
- inferenceReport === undefined ? uploadedOutput : `${uploadedOutput}\n${inferenceReport}`,
458
- ...(warning === undefined ? {} : { warning }),
480
+ output: [uploadedOutput, inferenceReport, addFieldsHint].filter((line) => line !== undefined).join("\n"),
481
+ ...(warnings.length === 0 ? {} : { warning: warnings.join("\n") }),
459
482
  };
460
483
  }
461
484
 
@@ -539,16 +562,19 @@ export async function loadDataset(
539
562
  options: LoadDatasetOptions,
540
563
  ): Promise<DatasetCommandResult> {
541
564
  const resolved = await resolveLiveDataset(client, options, false);
542
- const fieldNames: string[] = [];
565
+ const datasetRef = resolved.nameIsUnique ? resolved.name : resolved.datasetId;
566
+ const visibleFields: string[] = [];
543
567
  for await (const field of client.datasets.listAllFields({ datasetId: resolved.datasetId })) {
544
- if (field.internal || field.name === RAW_UPLOAD_FIELD_NAME) continue;
545
- fieldNames.push(field.name);
568
+ if (!field.internal) visibleFields.push(field.name);
546
569
  }
547
- assertCli(
548
- fieldNames.length > 0,
549
- `Dataset ${resolved.name} has no fields to export.`,
550
- { hint: { description: "Upload rows first with toloka dataset upload <name> <file>." } },
551
- );
570
+ assertCli(visibleFields.length > 0, `Dataset ${resolved.name} has no rows yet.`, {
571
+ hint: { description: `Upload rows with toloka dataset upload ${datasetRef} <file>.` },
572
+ });
573
+ const unknown = (options.fields ?? []).filter((name) => !visibleFields.includes(name));
574
+ assertCli(unknown.length === 0, `Dataset ${resolved.name} has no field ${unknown.join(", ")}.`, {
575
+ hint: { description: `Its fields are ${listedNames(visibleFields)}.` },
576
+ });
577
+ const fieldNames = options.fields === undefined ? visibleFields : [...options.fields];
552
578
 
553
579
  const started = await client.datasets.startExport({
554
580
  datasetId: resolved.datasetId,
@@ -587,7 +613,9 @@ export async function loadDataset(
587
613
  options.outPath,
588
614
  limited.length === 0 ? "" : `${limited.map((row) => JSON.stringify(row)).join("\n")}\n`,
589
615
  );
590
- return { output: `Wrote ${limited.length} rows to ${options.outPath}.` };
616
+ return {
617
+ output: `Wrote ${limited.length} rows with the fields ${listedNames(fieldNames)} to ${options.outPath}.`,
618
+ };
591
619
  }
592
620
 
593
621
  export async function setProjectSecret(
@@ -0,0 +1,80 @@
1
+ import type { DatasetField, PotatoClient } from "../client";
2
+ import type { WaitForOptions } from "../wait-for";
3
+ import { assertCli } from "./cli-error";
4
+ import { listedNames, resolveLiveDataset, summarizeJsonSchema } from "./data";
5
+ import { waitForFilledFields } from "./slices";
6
+ import { formatTable } from "./table";
7
+ import { uploadedRowFieldNames } from "./uploaded-fields";
8
+
9
+ export type AddDatasetFieldsOptions = {
10
+ readonly sourceRoot: string;
11
+ readonly datasetName: string;
12
+ readonly projectId?: string;
13
+ readonly fields: readonly string[];
14
+ readonly all: boolean;
15
+ readonly wait: boolean;
16
+ readonly waitFor?: WaitForOptions;
17
+ readonly json: boolean;
18
+ };
19
+
20
+ const FIELDS_HINT = {
21
+ description: "Name fields of the uploaded rows, for example label split, or pass --all; toloka dataset upload prints them.",
22
+ };
23
+
24
+ const FIELD_NAME = /^[a-z]([a-z0-9_]{0,126}[a-z0-9])?$/u;
25
+
26
+ const FIELD_NAME_RULE =
27
+ "a dataset field name takes lowercase letters, numbers and underscores, starts with a letter, ends with a letter or number, and has at most 128 characters";
28
+
29
+ export async function addDatasetFields(
30
+ client: PotatoClient,
31
+ options: AddDatasetFieldsOptions,
32
+ ): Promise<{ readonly output: string }> {
33
+ assertCli(!(options.all && options.fields.length > 0), "Name the fields or pass --all, not both.", { hint: FIELDS_HINT });
34
+ assertCli(options.all || options.fields.length > 0, "Name the fields of the uploaded rows to add, or pass --all.", {
35
+ hint: FIELDS_HINT,
36
+ });
37
+ const dataset = await resolveLiveDataset(client, options, false);
38
+ const datasetRef = dataset.nameIsUnique ? dataset.name : dataset.datasetId;
39
+ const existing = new Set<string>();
40
+ for await (const field of client.datasets.listAllFields({ datasetId: dataset.datasetId })) existing.add(field.name);
41
+ const candidates = options.all
42
+ ? (await uploadedRowFieldNames(client, dataset.datasetId)).filter((name) => !existing.has(name))
43
+ : [...options.fields];
44
+ const unnamable = candidates.filter((name) => !FIELD_NAME.test(name));
45
+ assertCli(options.all || unnamable.length === 0, `${unnamable.join(", ")} cannot be a dataset field name: ${FIELD_NAME_RULE}.`, {
46
+ hint: { description: "Rename the field in the file before uploading it, or pick fields with names a dataset field can take." },
47
+ });
48
+ const names = candidates.filter((name) => FIELD_NAME.test(name));
49
+ const skipped = unnamable.length === 0 ? [] : [`Skipped ${listedNames(unnamable)}: ${FIELD_NAME_RULE}.`];
50
+
51
+ if (names.length === 0) {
52
+ return {
53
+ output: [`Every field of the rows uploaded to ${dataset.name} that can be a dataset field is one already.`, ...skipped].join(
54
+ "\n",
55
+ ),
56
+ };
57
+ }
58
+
59
+ const created = await client.datasets.createFieldsFromUploadedRows({ datasetId: dataset.datasetId, names });
60
+ const fields: DatasetField[] = options.wait
61
+ ? (await waitForFilledFields(client, { datasetId: dataset.datasetId, names, waitFor: options.waitFor })).filter(
62
+ (field) => names.includes(field.name),
63
+ )
64
+ : created;
65
+
66
+ if (options.json) return { output: JSON.stringify(fields, null, 2) };
67
+
68
+ return {
69
+ output: [
70
+ formatTable([
71
+ ["FIELD", "ID", "TYPE"],
72
+ ...fields.map((field) => [field.name, field.id, summarizeJsonSchema(field.jsonSchema)]),
73
+ ]),
74
+ options.wait
75
+ ? `Copied the values of ${listedNames(names)} from the uploaded rows; later uploads fill them too.`
76
+ : `Copying the values of ${listedNames(names)} from the uploaded rows; check with toloka dataset columns ${datasetRef} --json.`,
77
+ ...skipped,
78
+ ].join("\n"),
79
+ };
80
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PotatoClient } from "../client";
2
- import { assertCli } from "./cli-error";
2
+ import { assertCli, REPORT_HINT } from "./cli-error";
3
+ import { uploadedFieldHint } from "./uploaded-fields";
3
4
 
4
5
  export type FieldValueFilter = {
5
6
  fieldValues: {
@@ -32,19 +33,27 @@ const splitFilter = (filter: string): { fieldName: string; rawValue: string } =>
32
33
 
33
34
  export const resolveFieldValueFilters = async (
34
35
  client: Pick<PotatoClient, "datasets">,
35
- datasetId: string,
36
+ dataset: { readonly datasetId: string; readonly name: string; readonly nameIsUnique: boolean },
36
37
  filters: readonly string[],
37
38
  ): Promise<FieldValueFilter> => {
39
+ const { datasetId } = dataset;
40
+ const datasetRef = dataset.nameIsUnique ? dataset.name : datasetId;
38
41
  const parsed = filters.map(splitFilter);
39
42
  const fieldIdsByName = new Map<string, string[]>();
40
43
  for await (const field of client.datasets.listAllFields({ datasetId })) {
41
44
  fieldIdsByName.set(field.name, [...(fieldIdsByName.get(field.name) ?? []), field.id]);
42
45
  }
46
+ const unresolved = parsed.find(({ fieldName }) => fieldIdsByName.get(fieldName)?.length !== 1)?.fieldName;
47
+ const uploadedHint =
48
+ unresolved === undefined || fieldIdsByName.has(unresolved)
49
+ ? undefined
50
+ : await uploadedFieldHint(client, { datasetId, datasetRef, name: unresolved });
51
+ assertCli(unresolved === undefined, `Dataset field ${unresolved} was not found or is ambiguous.`, {
52
+ hint: uploadedHint ?? { description: `Run toloka dataset columns ${datasetRef} to see the column names.` },
53
+ });
43
54
  const fieldValues = parsed.map(({ fieldName, rawValue }) => {
44
- const [fieldId, ...others] = fieldIdsByName.get(fieldName) ?? [];
45
- assertCli(fieldId && others.length === 0, `Dataset field ${fieldName} was not found or is ambiguous.`, {
46
- hint: { description: "Run toloka dataset columns <dataset> to see the column names." },
47
- });
55
+ const [fieldId] = fieldIdsByName.get(fieldName) ?? [];
56
+ assertCli(fieldId, `Dataset field ${fieldName} has no resolved ID.`, { hint: REPORT_HINT });
48
57
  const filter: FieldValueFilter["fieldValues"][number] = {
49
58
  fieldId,
50
59
  jsonPath: "$",
package/src/cli/slices.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DatasetSlice, PotatoClient } from "../client";
1
+ import type { DatasetField, DatasetSlice, PotatoClient } from "../client";
2
2
  import { PotatoApiError } from "../errors";
3
3
  import type { WaitForOptions } from "../wait-for";
4
4
  import { assertCli } from "./cli-error";
@@ -76,6 +76,19 @@ export const waitUntilDone = async <Resource>(
76
76
  }
77
77
  };
78
78
 
79
+ export const waitForFilledFields = (
80
+ client: Pick<PotatoClient, "waitFor">,
81
+ {
82
+ datasetId,
83
+ names,
84
+ waitFor,
85
+ }: { readonly datasetId: string; readonly names?: readonly string[]; readonly waitFor?: WaitForOptions },
86
+ ): Promise<DatasetField[]> =>
87
+ waitUntilDone(
88
+ () => client.waitFor({ type: "datasetFields", datasetId, ...(names === undefined ? {} : { names }) }, waitFor ?? {}),
89
+ { what: `the fields of dataset ${datasetId} to finish filling`, checkCommand: `dataset columns ${datasetId} --json` },
90
+ );
91
+
79
92
  const render = async (client: SliceApi, slice: DatasetSlice, json: boolean): Promise<DatasetSliceCommandResult> => ({
80
93
  output: json ? JSON.stringify(slice, null, 2) : formatSlice(slice, await fieldNamesOf(client, slice.datasetId)),
81
94
  });
@@ -91,6 +104,7 @@ export async function createDatasetSlice(
91
104
  );
92
105
  const dataset = await resolveLiveDataset(client, options, false);
93
106
  const frozenColumns = options.freeze && [...options.freeze];
107
+ if (options.wait) await waitForFilledFields(client, { datasetId: dataset.datasetId, waitFor: options.waitFor });
94
108
  const created = await client.datasets.createSlice(
95
109
  options.from === undefined
96
110
  ? {
@@ -98,7 +112,7 @@ export async function createDatasetSlice(
98
112
  name: options.name,
99
113
  ...(options.filters === undefined
100
114
  ? {}
101
- : { itemFilter: await resolveFieldValueFilters(client, dataset.datasetId, options.filters) }),
115
+ : { itemFilter: await resolveFieldValueFilters(client, dataset, options.filters) }),
102
116
  frozenColumns,
103
117
  }
104
118
  : { datasetId: dataset.datasetId, name: options.name, fromSlice: options.from, frozenColumns },
package/src/cli/splits.ts CHANGED
@@ -4,8 +4,9 @@ import { assertCli } from "./cli-error";
4
4
  import type { SplitPartSpec } from "./context";
5
5
  import { resolveLiveDataset } from "./data";
6
6
  import { resolveFieldValueFilters } from "./filters";
7
- import { fieldNamesOf, formatCount, waitUntilDone } from "./slices";
7
+ import { fieldNamesOf, formatCount, waitForFilledFields, waitUntilDone } from "./slices";
8
8
  import { formatTable } from "./table";
9
+ import { uploadedFieldHint } from "./uploaded-fields";
9
10
 
10
11
  type SplitApi = Pick<PotatoClient, "datasets" | "waitFor">;
11
12
 
@@ -30,6 +31,8 @@ export type SplitDatasetOptions = {
30
31
  readonly json: boolean;
31
32
  };
32
33
 
34
+ export const DEFAULT_SPLIT_COLUMN = "split";
35
+
33
36
  const formatPercent = (share: number): string => `${Math.round(share * 100)}%`;
34
37
 
35
38
  const presetParts = (ratios: SplitRatios, validateOnTest: boolean): SplitPartSpec[] => {
@@ -127,21 +130,73 @@ export const waitForSplitColumn = async (
127
130
  return done;
128
131
  };
129
132
 
133
+ const listFields = async (client: Pick<PotatoClient, "datasets">, datasetId: string): Promise<DatasetField[]> => {
134
+ const fields: DatasetField[] = [];
135
+ for await (const field of client.datasets.listAllFields({ datasetId })) fields.push(field);
136
+ return fields;
137
+ };
138
+
139
+ const assertFieldsToReadExist = async (
140
+ client: Pick<PotatoClient, "datasets">,
141
+ {
142
+ dataset,
143
+ fields,
144
+ column,
145
+ balanceBy,
146
+ }: {
147
+ dataset: { readonly datasetId: string; readonly name: string; readonly nameIsUnique: boolean };
148
+ fields: readonly DatasetField[];
149
+ column: string;
150
+ balanceBy: string | undefined;
151
+ },
152
+ ): Promise<void> => {
153
+ const datasetRef = dataset.nameIsUnique ? dataset.name : dataset.datasetId;
154
+ const isField = (name: string) => fields.some((field) => field.name === name || field.id === name);
155
+ if (!isField(column)) {
156
+ const uploaded = await uploadedFieldHint(client, { datasetId: dataset.datasetId, datasetRef, name: column });
157
+ assertCli(
158
+ uploaded === undefined,
159
+ `Dataset ${dataset.name} has no field ${column}, but its uploaded rows have one.`,
160
+ {
161
+ hint: {
162
+ description: `Add it as a dataset field with toloka dataset fields add ${datasetRef} ${column}, so the split takes its values as parts, or pass another --column for a new split column.`,
163
+ },
164
+ },
165
+ );
166
+ }
167
+ if (balanceBy !== undefined && !isField(balanceBy)) {
168
+ const uploaded = await uploadedFieldHint(client, { datasetId: dataset.datasetId, datasetRef, name: balanceBy });
169
+ assertCli(false, `Dataset ${dataset.name} has no field ${balanceBy} to balance by.`, {
170
+ hint: uploaded ?? { description: `List its fields with toloka dataset columns ${datasetRef}.` },
171
+ });
172
+ }
173
+ };
174
+
130
175
  export async function splitDataset(client: PotatoClient, options: SplitDatasetOptions): Promise<DatasetSplitCommandResult> {
131
176
  assertCli(options.slice === undefined || options.filters === undefined, "Pass --slice or --filter, not both.", {
132
177
  hint: { description: "--slice fills the rows of a slice; --filter fills the rows that match now." },
133
178
  });
179
+ const parts = requestedParts(options);
134
180
  const dataset = await resolveLiveDataset(client, options, false);
181
+ const fields = options.wait
182
+ ? await waitForFilledFields(client, { datasetId: dataset.datasetId, waitFor: options.waitFor })
183
+ : await listFields(client, dataset.datasetId);
184
+ await assertFieldsToReadExist(client, {
185
+ dataset,
186
+ fields,
187
+ column: options.column ?? DEFAULT_SPLIT_COLUMN,
188
+ balanceBy: options.balanceBy,
189
+ });
135
190
  const field = await client.datasets.split({
136
191
  datasetId: dataset.datasetId,
137
192
  column: options.column,
138
- parts: requestedParts(options),
193
+ parts,
139
194
  balanceBy: options.balanceBy,
140
195
  seed: options.seed,
141
196
  ...(options.slice === undefined ? {} : { slice: options.slice }),
142
197
  ...(options.filters === undefined
143
198
  ? {}
144
- : { itemFilter: await resolveFieldValueFilters(client, dataset.datasetId, options.filters) }),
199
+ : { itemFilter: await resolveFieldValueFilters(client, dataset, options.filters) }),
145
200
  });
146
201
  const done = options.wait ? await waitForSplitColumn(client, { datasetId: dataset.datasetId, field, waitFor: options.waitFor }) : field;
147
202
  return {
@@ -18,8 +18,9 @@ import type { BaseModelId, TrainingConfigurationInput } from "../../openapi/gen/
18
18
  import { assertCli, RETRY_OR_REPORT_HINT, TolokaCliError } from "./cli-error";
19
19
  import type { CliErrorHint } from "./cli-error";
20
20
  import { resolveLiveDataset } from "./data";
21
- import { formatCount, waitUntilDone } from "./slices";
22
- import { waitForSplitColumn } from "./splits";
21
+ import { formatCount, waitForFilledFields, waitUntilDone } from "./slices";
22
+ import { DEFAULT_SPLIT_COLUMN, waitForSplitColumn } from "./splits";
23
+ import { uploadedFieldHint } from "./uploaded-fields";
23
24
  import { formatTable, type TableIo as TrainingIo, writeOutput } from "./table";
24
25
 
25
26
  const ESTIMATE_FILE_HINT: CliErrorHint = {
@@ -161,10 +162,14 @@ const formatCostEstimate = (estimate: TrainingCostEstimate): string => {
161
162
  ].join("\n");
162
163
  }
163
164
 
165
+ const leftOutAsTest = estimate.inputs.training.heldOutRows ?? 0;
166
+
164
167
  return formatTable([
165
168
  ["Training", estimate.trainingId],
166
169
  ["Training rows", String(estimate.inputs.training.report?.validCount ?? 0)],
167
- ["Held-out test rows", String(estimate.inputs.training.heldOutRows ?? 0)],
170
+ ...(leftOutAsTest === 0
171
+ ? []
172
+ : [["Left out as test", `${formatCount(leftOutAsTest)} train rows that a split column now marks as test`]]),
168
173
  ["Training tokens", String(estimate.inputs.training.report?.tokens.total ?? 0)],
169
174
  ["Maximum GPU seconds", String(estimate.budget.maximumGpuSeconds)],
170
175
  ["Maximum charge", `$${estimate.maximumCharge} ${estimate.currency}`],
@@ -295,10 +300,14 @@ const splitColumnId = async (
295
300
 
296
301
  const fillTrainingSplitColumn = async (
297
302
  client: TrainingDraftApi,
298
- { datasetId, datasetName, splitColumn }: { datasetId: string; datasetName: string; splitColumn: string | undefined },
303
+ {
304
+ datasetId,
305
+ datasetName,
306
+ datasetRef,
307
+ splitColumn,
308
+ }: { datasetId: string; datasetName: string; datasetRef: string; splitColumn: string | undefined },
299
309
  ): Promise<DatasetField> => {
300
- const fields: DatasetField[] = [];
301
- for await (const field of client.datasets.listAllFields({ datasetId })) fields.push(field);
310
+ const fields = await waitForFilledFields(client, { datasetId, waitFor: AUTO_WAIT });
302
311
  const splitColumns = fields.filter((field) => field.split !== null);
303
312
  assertCli(
304
313
  splitColumn !== undefined || splitColumns.length <= 1,
@@ -312,9 +321,23 @@ const fillTrainingSplitColumn = async (
312
321
 
313
322
  if (existing?.split?.recipe === null) return existing;
314
323
 
324
+ const column = existing?.name ?? splitColumn ?? DEFAULT_SPLIT_COLUMN;
325
+ if (existing === undefined) {
326
+ const uploaded = await uploadedFieldHint(client, { datasetId, datasetRef, name: column });
327
+ assertCli(
328
+ uploaded === undefined,
329
+ `Dataset ${datasetName} has no field ${column}, but its uploaded rows have one.`,
330
+ {
331
+ hint: {
332
+ description: `Add it as a dataset field with toloka dataset fields add ${datasetRef} ${column} to train on its parts, or pass --split-column <name> to sample a new split column.`,
333
+ },
334
+ },
335
+ );
336
+ }
337
+
315
338
  return waitForSplitColumn(client, {
316
339
  datasetId,
317
- field: await client.datasets.split({ datasetId, column: existing?.name ?? splitColumn ?? "split" }),
340
+ field: await client.datasets.split({ datasetId, column }),
318
341
  waitFor: AUTO_WAIT,
319
342
  });
320
343
  };
@@ -348,6 +371,7 @@ const splitAndSlice = async (
348
371
  const column = await fillTrainingSplitColumn(client, {
349
372
  datasetId: dataset.datasetId,
350
373
  datasetName: dataset.name,
374
+ datasetRef: dataset.nameIsUnique ? dataset.name : dataset.datasetId,
351
375
  splitColumn,
352
376
  });
353
377
  const slice = await freezeTrainingSlice(client, { datasetId: dataset.datasetId, name: trainingName });
@@ -0,0 +1,20 @@
1
+ import type { PotatoClient } from "../client";
2
+ import type { CliErrorHint } from "./cli-error";
3
+
4
+ export const uploadedRowFieldNames = async (
5
+ client: Pick<PotatoClient, "datasets">,
6
+ datasetId: string,
7
+ ): Promise<string[]> =>
8
+ (await client.datasets.getInferredFields({ datasetId })).fields
9
+ .filter((field) => field.depth === 1)
10
+ .map((field) => field.name);
11
+
12
+ export const uploadedFieldHint = async (
13
+ client: Pick<PotatoClient, "datasets">,
14
+ { datasetId, datasetRef, name }: { readonly datasetId: string; readonly datasetRef: string; readonly name: string },
15
+ ): Promise<CliErrorHint | undefined> =>
16
+ (await uploadedRowFieldNames(client, datasetId)).includes(name)
17
+ ? {
18
+ description: `${name} is a field of the uploaded rows but not a dataset field yet. Add it with toloka dataset fields add ${datasetRef} ${name}, then run this again.`,
19
+ }
20
+ : undefined;