@toloka-platform/sdk 0.11.2 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/toloka.js +158 -154
- package/dist/cli/toloka.js.jsc +0 -0
- package/dist/cli/toloka.js.jsc.sha256 +1 -1
- package/dist/cli/toloka.js.sha256 +1 -1
- package/docs/authoring.md +6 -4
- package/docs/cli.md +30 -11
- package/docs/limitations.md +2 -2
- package/docs/testing.md +4 -2
- package/examples/README.md +11 -0
- package/examples/app-crowdtesting/README.md +41 -0
- package/examples/app-crowdtesting/fields.ts +6 -0
- package/examples/app-crowdtesting/nodes/app-flow-test/node.ts +381 -0
- package/examples/app-crowdtesting/nodes/collection/node.ts +8 -0
- package/examples/app-crowdtesting/pipeline.browser.integration.spec.ts +77 -0
- package/examples/app-crowdtesting/pipeline.ts +17 -0
- package/examples/egocentric-video-collection/README.md +35 -0
- package/examples/egocentric-video-collection/fields.ts +24 -0
- package/examples/egocentric-video-collection/nodes/collection/node.ts +5 -0
- package/examples/egocentric-video-collection/nodes/record-activity/node.ts +760 -0
- package/examples/egocentric-video-collection/pipeline.browser.integration.spec.ts +65 -0
- package/examples/egocentric-video-collection/pipeline.ts +17 -0
- package/examples/product-moderation/README.md +49 -0
- package/examples/product-moderation/fields.ts +37 -0
- package/examples/product-moderation/nodes/collection/node.ts +16 -0
- package/examples/product-moderation/nodes/moderate-product/instruction.ts +77 -0
- package/examples/product-moderation/nodes/moderate-product/node.ts +368 -0
- package/examples/product-moderation/pipeline.browser.integration.spec.ts +48 -0
- package/examples/product-moderation/pipeline.ts +33 -0
- package/examples/video-pair-rating/fields.ts +16 -4
- package/examples/video-pair-rating/nodes/adjudicate/node.ts +78 -3
- package/examples/video-pair-rating/nodes/adjudicate_op/node.ts +28 -3
- package/examples/video-pair-rating/nodes/overall_preference_pick/node.ts +28 -3
- package/examples/video-pair-rating/nodes/rate_pair/node.ts +65 -2
- package/openapi/gen/api.ts +22 -0
- package/openapi/gen/models/generationBudget.ts +8 -0
- package/openapi/gen/models/generationBudgetReasoningEffort.ts +2 -0
- package/openapi/gen/models/generationModel.ts +12 -0
- package/openapi/gen/models/generationModelDefaultReasoningEffort.ts +2 -0
- package/openapi/gen/models/generationModelId.ts +2 -0
- package/openapi/gen/models/index.ts +8 -0
- package/openapi/gen/models/invalidFieldSchema.ts +6 -0
- package/openapi/gen/models/listGenerationModels200.ts +7 -0
- package/openapi/gen/models/listGenerationModels200DefaultGenerationModelId.ts +2 -0
- package/openapi/gen/models/pipelineGraphSnapshot.ts +2 -0
- package/package.json +2 -2
- package/skills/catalog.json +16 -12
- package/skills/toloka-build/SKILL.md +44 -58
- package/skills/toloka-build/references/human-work.md +156 -0
- package/skills/toloka-build/references/local-setup.md +136 -0
- package/skills/toloka-build/references/node-checks.md +11 -0
- package/skills/toloka-build/references/node-semantics.md +22 -213
- package/skills/toloka-build/references/task-ui.md +61 -0
- package/skills/toloka-build/references/workflow-and-cli.md +34 -228
- package/skills/toloka-fine-tuning/SKILL.md +1 -1
- package/skills/toloka-get-started/SKILL.md +1 -1
- package/skills/toloka-gisting/SKILL.md +1 -1
- package/src/authoring/index.ts +26 -7
- package/src/checks/task-ui-typecheck.ts +3 -3
- package/src/cli/analytics.ts +1 -0
- package/src/cli/check.ts +83 -12
- package/src/cli/commands/generation-model.ts +18 -0
- package/src/cli/commands/pipeline.ts +1 -1
- package/src/cli/commands/source.ts +14 -3
- package/src/cli/data.ts +5 -2
- package/src/cli/format-error.ts +3 -0
- package/src/cli/generation-models.ts +46 -0
- package/src/cli/program.ts +2 -0
- package/src/cli/project.ts +288 -138
- package/src/cli/trainings.ts +12 -0
- package/src/cli/ui.ts +3 -1
- package/src/client.ts +5 -0
- package/src/graph-rules.json +11 -0
- package/src/importer.ts +46 -40
- package/src/index.ts +4 -0
- package/src/transport.ts +23 -5
- package/src/validate.ts +183 -0
package/src/importer.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { assert } from "es-toolkit";
|
|
2
|
-
import type { JsonSchema, PipelineGraphV1 } from "../openapi/gen/models";
|
|
2
|
+
import type { InvalidFieldSchema, JsonSchema, PipelineGraphV1 } from "../openapi/gen/models";
|
|
3
3
|
import type { AuthoringIdOptions } from "./authoring";
|
|
4
4
|
|
|
5
5
|
export type PipelineSourceTree = Readonly<Record<string, string>>;
|
|
6
6
|
export type GeneratePipelineSourceTreeOptions = {
|
|
7
|
-
readonly dataset
|
|
7
|
+
readonly dataset?: string;
|
|
8
|
+
readonly invalidFieldSchemas?: readonly InvalidFieldSchema[];
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
export type FieldSourceDefinition = {
|
|
11
12
|
readonly name: string;
|
|
12
13
|
readonly jsonSchema: JsonSchema;
|
|
14
|
+
readonly invalidJsonSchema?: Omit<InvalidFieldSchema, "fieldId">;
|
|
13
15
|
readonly options?: AuthoringIdOptions;
|
|
14
16
|
};
|
|
15
17
|
|
|
@@ -263,30 +265,11 @@ function transformExam<Exam extends { gradedEntities: Array<{ targetEntity: stri
|
|
|
263
265
|
};
|
|
264
266
|
}
|
|
265
267
|
|
|
266
|
-
const
|
|
267
|
-
"ui",
|
|
268
|
-
"llmqa",
|
|
269
|
-
"qc_lint",
|
|
270
|
-
"ui_types_lint",
|
|
271
|
-
"exam_dataset",
|
|
272
|
-
"synthgen",
|
|
273
|
-
];
|
|
274
|
-
const quorumCheckNames: readonly string[] = [
|
|
275
|
-
"ui",
|
|
276
|
-
"llmqa",
|
|
277
|
-
"qc_lint",
|
|
278
|
-
"ui_types_lint",
|
|
279
|
-
"exam_dataset",
|
|
280
|
-
];
|
|
281
|
-
|
|
282
|
-
const normalizeImportedChecks = (
|
|
268
|
+
const withoutEmptyChecks = (
|
|
283
269
|
checks: Record<string, unknown[]> | undefined,
|
|
284
|
-
requiredNames: readonly string[],
|
|
285
270
|
): Record<string, unknown[]> | undefined => {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
for (const name of requiredNames) normalized[name] ??= [];
|
|
289
|
-
return normalized;
|
|
271
|
+
const kept = Object.entries(checks ?? {}).filter(([, cases]) => cases.length > 0);
|
|
272
|
+
return kept.length === 0 ? undefined : Object.fromEntries(kept);
|
|
290
273
|
};
|
|
291
274
|
|
|
292
275
|
function authoringSettings(node: GraphNode, fieldReference: FieldReference): unknown {
|
|
@@ -310,9 +293,11 @@ function authoringSettings(node: GraphNode, fieldReference: FieldReference): unk
|
|
|
310
293
|
};
|
|
311
294
|
}
|
|
312
295
|
case "code": {
|
|
313
|
-
const { fieldProxies, ...settings } = node.settings;
|
|
296
|
+
const { fieldProxies, checks, ...settings } = node.settings;
|
|
297
|
+
const authoredChecks = withoutEmptyChecks(checks);
|
|
314
298
|
return {
|
|
315
299
|
...settings,
|
|
300
|
+
...(authoredChecks === undefined ? {} : { checks: authoredChecks }),
|
|
316
301
|
input: fieldProxies.input.map((reference) =>
|
|
317
302
|
transformFieldReference(reference, fieldReference),
|
|
318
303
|
),
|
|
@@ -323,10 +308,10 @@ function authoringSettings(node: GraphNode, fieldReference: FieldReference): unk
|
|
|
323
308
|
}
|
|
324
309
|
case "generation": {
|
|
325
310
|
const { entities, qualityCriteria, exam, checks, ...settings } = node.settings;
|
|
326
|
-
const
|
|
311
|
+
const authoredChecks = withoutEmptyChecks(checks);
|
|
327
312
|
return {
|
|
328
313
|
...settings,
|
|
329
|
-
...(
|
|
314
|
+
...(authoredChecks === undefined ? {} : { checks: authoredChecks }),
|
|
330
315
|
entities: {
|
|
331
316
|
input: entities.input.map((entity) => transformEntity(entity, fieldReference)),
|
|
332
317
|
output: entities.output.map((entity) => transformEntity(entity, fieldReference)),
|
|
@@ -355,10 +340,10 @@ function authoringSettings(node: GraphNode, fieldReference: FieldReference): unk
|
|
|
355
340
|
}
|
|
356
341
|
case "quorum": {
|
|
357
342
|
const { entities, qualityCriteria, exam, checks, ...settings } = node.settings;
|
|
358
|
-
const
|
|
343
|
+
const authoredChecks = withoutEmptyChecks(checks);
|
|
359
344
|
return {
|
|
360
345
|
...settings,
|
|
361
|
-
...(
|
|
346
|
+
...(authoredChecks === undefined ? {} : { checks: authoredChecks }),
|
|
362
347
|
entities: {
|
|
363
348
|
input: entities.input.map((entity) => transformEntity(entity, fieldReference)),
|
|
364
349
|
output: entities.output.map((entity) => transformEntity(entity, fieldReference)),
|
|
@@ -419,9 +404,13 @@ function fieldDeclarationsSource(
|
|
|
419
404
|
const declarations = fields
|
|
420
405
|
.map((fieldDefinition) => {
|
|
421
406
|
const binding = bindingFor(fieldDefinition);
|
|
407
|
+
const jsonSchemaSource =
|
|
408
|
+
fieldDefinition.invalidJsonSchema === undefined
|
|
409
|
+
? formatSource(fieldDefinition.jsonSchema, 1)
|
|
410
|
+
: `invalidJsonSchema(${formatSource({ field: fieldDefinition.name, ...fieldDefinition.invalidJsonSchema }, 1)})`;
|
|
422
411
|
const argumentsSource = [
|
|
423
412
|
`${indentation(1)}${formatSource(fieldDefinition.name, 1)}`,
|
|
424
|
-
`${indentation(1)}${
|
|
413
|
+
`${indentation(1)}${jsonSchemaSource}`,
|
|
425
414
|
...(fieldDefinition.options === undefined
|
|
426
415
|
? []
|
|
427
416
|
: [`${indentation(1)}${formatSource(fieldDefinition.options, 1)}`]),
|
|
@@ -432,7 +421,10 @@ function fieldDeclarationsSource(
|
|
|
432
421
|
};
|
|
433
422
|
})
|
|
434
423
|
.toSorted((left, right) => compareText(left.binding, right.binding));
|
|
435
|
-
|
|
424
|
+
const imports = fields.some((fieldDefinition) => fieldDefinition.invalidJsonSchema !== undefined)
|
|
425
|
+
? "field, invalidJsonSchema"
|
|
426
|
+
: "field";
|
|
427
|
+
return `import { ${imports} } from '@toloka-platform/sdk';\n\n${declarations.map(({ source }) => source).join("\n\n")}\n`;
|
|
436
428
|
}
|
|
437
429
|
|
|
438
430
|
export function generateFieldDeclarationsSource(
|
|
@@ -462,13 +454,23 @@ export function generateFieldDeclarationsSource(
|
|
|
462
454
|
});
|
|
463
455
|
}
|
|
464
456
|
|
|
465
|
-
function fieldSource(
|
|
457
|
+
function fieldSource(
|
|
458
|
+
graph: PipelineGraphV1,
|
|
459
|
+
fieldBindings: ReadonlyMap<string, string>,
|
|
460
|
+
invalidFieldSchemas: readonly InvalidFieldSchema[],
|
|
461
|
+
): string {
|
|
466
462
|
return fieldDeclarationsSource(
|
|
467
|
-
graph.fields.map((fieldDefinition) =>
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
463
|
+
graph.fields.map((fieldDefinition) => {
|
|
464
|
+
const invalid = invalidFieldSchemas.find(({ fieldId }) => fieldId === fieldDefinition.id);
|
|
465
|
+
return {
|
|
466
|
+
name: fieldDefinition.name,
|
|
467
|
+
jsonSchema: fieldDefinition.jsonSchema,
|
|
468
|
+
...(invalid === undefined
|
|
469
|
+
? {}
|
|
470
|
+
: { invalidJsonSchema: { reason: invalid.reason, text: invalid.text } }),
|
|
471
|
+
options: { id: fieldDefinition.id },
|
|
472
|
+
};
|
|
473
|
+
}),
|
|
472
474
|
(fieldDefinition) => {
|
|
473
475
|
const fieldId = fieldDefinition.options?.id;
|
|
474
476
|
assert(fieldId, `Field "${fieldDefinition.name}" has no source ID`);
|
|
@@ -527,7 +529,7 @@ function endpointSource(
|
|
|
527
529
|
|
|
528
530
|
function pipelineSource(
|
|
529
531
|
graph: PipelineGraphV1,
|
|
530
|
-
dataset: string,
|
|
532
|
+
dataset: string | undefined,
|
|
531
533
|
nodeEmissions: ReadonlyArray<NodeEmission>,
|
|
532
534
|
fieldBindings: ReadonlyMap<string, string>,
|
|
533
535
|
nodeBindings: ReadonlyMap<string, string>,
|
|
@@ -568,7 +570,8 @@ function pipelineSource(
|
|
|
568
570
|
if (values.length === 0) return "[]";
|
|
569
571
|
return `[\n${values.map((value) => `${indentation(depth + 1)}${value}`).join(",\n")}\n${indentation(depth)}]`;
|
|
570
572
|
};
|
|
571
|
-
|
|
573
|
+
const datasetLine = dataset === undefined ? "" : ` dataset: ${formatSource(dataset)},\n`;
|
|
574
|
+
return `${imports.join("\n")}\n\nexport default definePipeline({\n${datasetLine} fields: ${formatList(fieldReferences, 1)},\n nodes: ${formatList(nodeReferences, 1)},\n connections: ${formatList(connectionReferences, 1)}\n});\n`;
|
|
572
575
|
}
|
|
573
576
|
|
|
574
577
|
export function generatePipelineSourceTree(
|
|
@@ -621,7 +624,10 @@ export function generatePipelineSourceTree(
|
|
|
621
624
|
return nodeSource(node, binding, `nodes/${directory}/node.ts`, fieldBindings);
|
|
622
625
|
});
|
|
623
626
|
const entries = [
|
|
624
|
-
{
|
|
627
|
+
{
|
|
628
|
+
path: "fields.ts",
|
|
629
|
+
source: fieldSource(graph, fieldBindings, options.invalidFieldSchemas ?? []),
|
|
630
|
+
},
|
|
625
631
|
...nodeEmissions.map(({ path, source }) => ({ path, source })),
|
|
626
632
|
{
|
|
627
633
|
path: "pipeline.ts",
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ export type {
|
|
|
42
42
|
CodeNode,
|
|
43
43
|
CodeNodeSettings,
|
|
44
44
|
CurrentUser,
|
|
45
|
+
GenerationBudget,
|
|
46
|
+
GenerationModel,
|
|
45
47
|
GenerationNode,
|
|
46
48
|
GenerationNodeSettings,
|
|
47
49
|
JsonSchema,
|
|
@@ -72,6 +74,7 @@ export {
|
|
|
72
74
|
connect,
|
|
73
75
|
definePipeline,
|
|
74
76
|
field,
|
|
77
|
+
invalidJsonSchema,
|
|
75
78
|
optional,
|
|
76
79
|
generation,
|
|
77
80
|
isPipelineDefinition,
|
|
@@ -92,6 +95,7 @@ export type {
|
|
|
92
95
|
Connection,
|
|
93
96
|
Field,
|
|
94
97
|
GenerationAuthoringSettings,
|
|
98
|
+
InvalidJsonSchemaSource,
|
|
95
99
|
InputPort,
|
|
96
100
|
MatchAuthoringSettings,
|
|
97
101
|
OutputPort,
|
package/src/transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assert } from "es-toolkit";
|
|
1
|
+
import { assert, delay } from "es-toolkit";
|
|
2
2
|
import { PotatoApiError } from "./errors";
|
|
3
3
|
|
|
4
4
|
const BASE_URL_HEADER = "x-potato-sdk-base-url";
|
|
@@ -14,9 +14,16 @@ export type ClientCredential =
|
|
|
14
14
|
}
|
|
15
15
|
| { readonly scopedToken: string; readonly accessToken?: undefined; readonly apiKey?: undefined };
|
|
16
16
|
|
|
17
|
+
const RELAY_HEADER = "x-potato-relay";
|
|
18
|
+
const TUNNEL_UNAVAILABLE = "tunnel-unavailable";
|
|
19
|
+
export const TUNNEL_RETRY_DELAYS_MS: readonly number[] = [
|
|
20
|
+
1_000, 2_000, 4_000, 8_000, 15_000, 15_000, 15_000, 15_000, 15_000,
|
|
21
|
+
];
|
|
22
|
+
|
|
17
23
|
type TransportRequestInit = RequestInit & {
|
|
18
24
|
readonly fetch?: typeof globalThis.fetch;
|
|
19
25
|
readonly authorization?: () => Promise<string>;
|
|
26
|
+
readonly tunnelRetryDelaysMs?: readonly number[];
|
|
20
27
|
};
|
|
21
28
|
|
|
22
29
|
export const createRequestOptions = (
|
|
@@ -79,6 +86,7 @@ export async function potatoFetch(url: string, options: TransportRequestInit): P
|
|
|
79
86
|
const {
|
|
80
87
|
fetch: fetchImplementation = globalThis.fetch,
|
|
81
88
|
authorization,
|
|
89
|
+
tunnelRetryDelaysMs = TUNNEL_RETRY_DELAYS_MS,
|
|
82
90
|
...requestOptions
|
|
83
91
|
} = options;
|
|
84
92
|
const headers = new Headers(requestOptions.headers);
|
|
@@ -87,10 +95,20 @@ export async function potatoFetch(url: string, options: TransportRequestInit): P
|
|
|
87
95
|
headers.delete(BASE_URL_HEADER);
|
|
88
96
|
if (authorization !== undefined) headers.set("Authorization", await authorization());
|
|
89
97
|
|
|
90
|
-
const
|
|
91
|
-
resolveRequestUrl(url, baseUrl),
|
|
92
|
-
|
|
93
|
-
);
|
|
98
|
+
const send = () =>
|
|
99
|
+
fetchImplementation(resolveRequestUrl(url, baseUrl), prepareRequestInit(requestOptions, headers));
|
|
100
|
+
|
|
101
|
+
let response = await send();
|
|
102
|
+
const replayableBody = !(typeof ReadableStream !== "undefined" && requestOptions.body instanceof ReadableStream);
|
|
103
|
+
|
|
104
|
+
for (const delayMs of replayableBody ? tunnelRetryDelaysMs : []) {
|
|
105
|
+
if (response.status !== 503 || response.headers.get(RELAY_HEADER) !== TUNNEL_UNAVAILABLE) break;
|
|
106
|
+
|
|
107
|
+
await response.body?.cancel();
|
|
108
|
+
await delay(delayMs, { signal: requestOptions.signal ?? undefined });
|
|
109
|
+
response = await send();
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
const text = await response.text();
|
|
95
113
|
|
|
96
114
|
if (!response.ok) {
|
package/src/validate.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { assert } from 'es-toolkit';
|
|
|
4
4
|
import type { PipelineGraphV1 } from '../openapi/gen/models';
|
|
5
5
|
import coreEntityTypes from './core-entity-types.json' with { type: 'json' };
|
|
6
6
|
import { PipelineGraphValidationError, type PipelineGraphValidationIssue } from './errors';
|
|
7
|
+
import graphRules from './graph-rules.json' with { type: 'json' };
|
|
7
8
|
import pipelineGraphV1Contract from './pipeline-graph-v1.contract.json' with { type: 'json' };
|
|
8
9
|
|
|
9
10
|
const ajv = new Ajv2020({ strict: false });
|
|
@@ -52,6 +53,188 @@ const graphEntityIssues = (graph: PipelineGraphV1): PipelineGraphValidationIssue
|
|
|
52
53
|
];
|
|
53
54
|
});
|
|
54
55
|
|
|
56
|
+
const NAME_PATTERN = new RegExp(graphRules.namePattern);
|
|
57
|
+
const MEDIA_UPLOAD_COMPONENT_PATTERN = new RegExp(graphRules.mediaUploadComponentPattern);
|
|
58
|
+
|
|
59
|
+
type GraphNode = PipelineGraphV1['nodes'][number];
|
|
60
|
+
type HumanWorkNode = Extract<GraphNode, { type: 'generation' | 'quorum' }>;
|
|
61
|
+
type RuleNode = { readonly id: string; readonly type: 'root' } | Exclude<GraphNode, { type: 'root' }>;
|
|
62
|
+
type RuleGraph = {
|
|
63
|
+
readonly nodes: ReadonlyArray<RuleNode>;
|
|
64
|
+
readonly connections: ReadonlyArray<PipelineGraphV1['connections'][number]>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const duplicateIndex = (handle: string) => Number(/\[(\d+)\]$/.exec(handle)?.[1] ?? 0);
|
|
68
|
+
|
|
69
|
+
const nodesInUse = (graph: RuleGraph): Set<string> => {
|
|
70
|
+
const root = graph.nodes.find((node) => node.type === 'root');
|
|
71
|
+
if (root === undefined) return new Set();
|
|
72
|
+
const inUse = new Set([root.id]);
|
|
73
|
+
const visited = new Set<string>();
|
|
74
|
+
const pending: [string, number][] = [[root.id, 0]];
|
|
75
|
+
for (let state = pending.pop(); state !== undefined; state = pending.pop()) {
|
|
76
|
+
const [nodeId, duplicate] = state;
|
|
77
|
+
for (const connection of graph.connections) {
|
|
78
|
+
if (connection.from.node !== nodeId || duplicateIndex(connection.from.handle) !== duplicate) continue;
|
|
79
|
+
if (connection.to.node === root.id) continue;
|
|
80
|
+
const next: [string, number] = [connection.to.node, duplicateIndex(connection.to.handle)];
|
|
81
|
+
if (visited.has(next.join('#'))) continue;
|
|
82
|
+
visited.add(next.join('#'));
|
|
83
|
+
inUse.add(next[0]);
|
|
84
|
+
pending.push(next);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return inUse;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const toSnakeCase = (value: string) =>
|
|
91
|
+
value
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
94
|
+
.replace(/^_|_$/g, '');
|
|
95
|
+
|
|
96
|
+
const nodeNameIssues = (graph: RuleGraph, node: RuleNode, prefix: string): PipelineGraphValidationIssue[] => {
|
|
97
|
+
if (node.type === 'root') return [];
|
|
98
|
+
const path = `${prefix}/about/name`;
|
|
99
|
+
const { name } = node.settings.about;
|
|
100
|
+
if (name === '') return [{ path, reason: 'The node must have a name' }];
|
|
101
|
+
if (name.length < graphRules.nodeNameMinLength || name.length > graphRules.nodeNameMaxLength) {
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
path,
|
|
105
|
+
reason: `The name must be between ${graphRules.nodeNameMinLength} and ${graphRules.nodeNameMaxLength} characters long`,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
if (!NAME_PATTERN.test(name)) {
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
path,
|
|
113
|
+
reason:
|
|
114
|
+
'The name must start with a letter, can contain only lowercase letters, numbers and underscores, and must end with a letter or number',
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
const duplicated = graph.nodes.some(
|
|
119
|
+
(other) => other.id !== node.id && other.type !== 'root' && other.settings.about.name === name,
|
|
120
|
+
);
|
|
121
|
+
return duplicated ? [{ path, reason: 'The name must be unique across all nodes' }] : [];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type HumanWorkSettings = HumanWorkNode['settings'];
|
|
125
|
+
|
|
126
|
+
const thresholdIssues = (
|
|
127
|
+
criteria: HumanWorkSettings['qualityCriteria'],
|
|
128
|
+
prefix: string,
|
|
129
|
+
): PipelineGraphValidationIssue[] =>
|
|
130
|
+
criteria.flatMap((criterion, index) =>
|
|
131
|
+
(['pass', 'fail'] as const)
|
|
132
|
+
.filter((side) => criterion.thresholds[side].length < graphRules.minQualityThresholdLength)
|
|
133
|
+
.map((side) => ({
|
|
134
|
+
path: `${prefix}/qualityCriteria/${index}/thresholds/${side}`,
|
|
135
|
+
reason: `${side === 'pass' ? 'Pass' : 'Fail'} condition must be at least ${graphRules.minQualityThresholdLength} characters`,
|
|
136
|
+
})),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const qualityElementIssues = (
|
|
140
|
+
criteria: HumanWorkSettings['qualityCriteria'],
|
|
141
|
+
prefix: string,
|
|
142
|
+
): PipelineGraphValidationIssue[] => {
|
|
143
|
+
const keys = criteria.map((criterion) =>
|
|
144
|
+
criterion.qualityElement === '' ? undefined : toSnakeCase(criterion.qualityElement),
|
|
145
|
+
);
|
|
146
|
+
return keys.flatMap((key, index) =>
|
|
147
|
+
key !== undefined && keys.indexOf(key) !== keys.lastIndexOf(key)
|
|
148
|
+
? [{ path: `${prefix}/qualityCriteria/${index}/qualityElement`, reason: 'Quality element names must be unique' }]
|
|
149
|
+
: [],
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const humanQaIssues = (settings: HumanWorkSettings, prefix: string): PipelineGraphValidationIssue[] => {
|
|
154
|
+
const { qualityAssurance, qualityCriteria } = settings;
|
|
155
|
+
if (qualityAssurance?.qaMode !== 'human_only' && qualityAssurance?.qaMode !== 'hybrid') return [];
|
|
156
|
+
const minimum = Math.max(
|
|
157
|
+
graphRules.minHumanQaTimeForTaskSeconds,
|
|
158
|
+
graphRules.humanQaSecondsPerQualityCriterion * qualityCriteria.length,
|
|
159
|
+
);
|
|
160
|
+
return (qualityAssurance.humanQaTimeForTask ?? 0) < minimum
|
|
161
|
+
? [
|
|
162
|
+
{
|
|
163
|
+
path: `${prefix}/qualityAssurance/humanQaTimeForTask`,
|
|
164
|
+
reason: `Time for one Human QA review must be at least ${minimum} seconds`,
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
: [];
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const isSynthetic = ({ audienceAndPricing }: HumanWorkSettings) => audienceAndPricing?.audienceType === 'synthetic';
|
|
171
|
+
|
|
172
|
+
const humanAudienceIssues = (settings: HumanWorkSettings, prefix: string): PipelineGraphValidationIssue[] => {
|
|
173
|
+
const { about, audienceAndPricing, ui } = settings;
|
|
174
|
+
if (isSynthetic(settings)) return [];
|
|
175
|
+
const issues: PipelineGraphValidationIssue[] = [];
|
|
176
|
+
if (about.projectType === 'data_collection' && !MEDIA_UPLOAD_COMPONENT_PATTERN.test(ui.code)) {
|
|
177
|
+
issues.push({
|
|
178
|
+
path: `${prefix}/ui/code`,
|
|
179
|
+
reason:
|
|
180
|
+
'Data collection projects require a media upload component (VideoUpload, VideoRecord, or FileUpload) in the UI',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (audienceAndPricing?.audienceType !== 'human') return issues;
|
|
184
|
+
if (audienceAndPricing.timeForTask < graphRules.minTimeForTaskSeconds) {
|
|
185
|
+
issues.push({
|
|
186
|
+
path: `${prefix}/audienceAndPricing/timeForTask`,
|
|
187
|
+
reason: `The task execution time cannot be less than ${graphRules.minTimeForTaskSeconds} seconds`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (audienceAndPricing.pricePerTask <= 0) {
|
|
191
|
+
issues.push({ path: `${prefix}/audienceAndPricing/pricePerTask`, reason: 'The price per task must be greater than 0' });
|
|
192
|
+
}
|
|
193
|
+
return issues;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const examIssues = (settings: HumanWorkSettings, prefix: string): PipelineGraphValidationIssue[] => {
|
|
197
|
+
const { exam } = settings;
|
|
198
|
+
if (!exam?.enabled || isSynthetic(settings)) return [];
|
|
199
|
+
const issues: PipelineGraphValidationIssue[] = [];
|
|
200
|
+
if (exam.tasksPerExpert > graphRules.maxExamTasksPerExpert) {
|
|
201
|
+
issues.push({
|
|
202
|
+
path: `${prefix}/exam/tasksPerExpert`,
|
|
203
|
+
reason: `Tasks per expert cannot exceed ${graphRules.maxExamTasksPerExpert}`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (exam.passThreshold < 1) {
|
|
207
|
+
issues.push({ path: `${prefix}/exam/passThreshold`, reason: 'Pass threshold must be at least 1' });
|
|
208
|
+
}
|
|
209
|
+
if (exam.passThreshold > exam.tasksPerExpert) {
|
|
210
|
+
issues.push({ path: `${prefix}/exam/passThreshold`, reason: 'Pass threshold cannot exceed tasks per expert' });
|
|
211
|
+
}
|
|
212
|
+
return issues;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const humanWorkIssues = ({ settings }: HumanWorkNode, prefix: string): PipelineGraphValidationIssue[] => [
|
|
216
|
+
...thresholdIssues(settings.qualityCriteria, prefix),
|
|
217
|
+
...qualityElementIssues(settings.qualityCriteria, prefix),
|
|
218
|
+
...humanQaIssues(settings, prefix),
|
|
219
|
+
...humanAudienceIssues(settings, prefix),
|
|
220
|
+
...examIssues(settings, prefix),
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
export type PlatformRuleIssue = PipelineGraphValidationIssue & { readonly node: string };
|
|
224
|
+
|
|
225
|
+
export const platformRuleIssues = (graph: RuleGraph): PlatformRuleIssue[] => {
|
|
226
|
+
const inUse = nodesInUse(graph);
|
|
227
|
+
return graph.nodes.flatMap((node, index) => {
|
|
228
|
+
if (!inUse.has(node.id)) return [];
|
|
229
|
+
const prefix = `/nodes/${index}/settings`;
|
|
230
|
+
const label = node.type === 'root' || node.settings.about.name === '' ? node.id : node.settings.about.name;
|
|
231
|
+
return [
|
|
232
|
+
...nodeNameIssues(graph, node, prefix),
|
|
233
|
+
...(node.type === 'generation' || node.type === 'quorum' ? humanWorkIssues(node, prefix) : []),
|
|
234
|
+
].map((issue) => ({ ...issue, node: label }));
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
55
238
|
const toValidationIssue = (error: ErrorObject): PipelineGraphValidationIssue => {
|
|
56
239
|
const property =
|
|
57
240
|
error.keyword === 'required'
|