@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.
Files changed (76) hide show
  1. package/dist/cli/toloka.js +158 -154
  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/authoring.md +6 -4
  6. package/docs/cli.md +30 -11
  7. package/docs/limitations.md +2 -2
  8. package/docs/testing.md +4 -2
  9. package/examples/README.md +11 -0
  10. package/examples/app-crowdtesting/README.md +41 -0
  11. package/examples/app-crowdtesting/fields.ts +6 -0
  12. package/examples/app-crowdtesting/nodes/app-flow-test/node.ts +381 -0
  13. package/examples/app-crowdtesting/nodes/collection/node.ts +8 -0
  14. package/examples/app-crowdtesting/pipeline.browser.integration.spec.ts +77 -0
  15. package/examples/app-crowdtesting/pipeline.ts +17 -0
  16. package/examples/egocentric-video-collection/README.md +35 -0
  17. package/examples/egocentric-video-collection/fields.ts +24 -0
  18. package/examples/egocentric-video-collection/nodes/collection/node.ts +5 -0
  19. package/examples/egocentric-video-collection/nodes/record-activity/node.ts +760 -0
  20. package/examples/egocentric-video-collection/pipeline.browser.integration.spec.ts +65 -0
  21. package/examples/egocentric-video-collection/pipeline.ts +17 -0
  22. package/examples/product-moderation/README.md +49 -0
  23. package/examples/product-moderation/fields.ts +37 -0
  24. package/examples/product-moderation/nodes/collection/node.ts +16 -0
  25. package/examples/product-moderation/nodes/moderate-product/instruction.ts +77 -0
  26. package/examples/product-moderation/nodes/moderate-product/node.ts +368 -0
  27. package/examples/product-moderation/pipeline.browser.integration.spec.ts +48 -0
  28. package/examples/product-moderation/pipeline.ts +33 -0
  29. package/examples/video-pair-rating/fields.ts +16 -4
  30. package/examples/video-pair-rating/nodes/adjudicate/node.ts +78 -3
  31. package/examples/video-pair-rating/nodes/adjudicate_op/node.ts +28 -3
  32. package/examples/video-pair-rating/nodes/overall_preference_pick/node.ts +28 -3
  33. package/examples/video-pair-rating/nodes/rate_pair/node.ts +65 -2
  34. package/openapi/gen/api.ts +22 -0
  35. package/openapi/gen/models/generationBudget.ts +8 -0
  36. package/openapi/gen/models/generationBudgetReasoningEffort.ts +2 -0
  37. package/openapi/gen/models/generationModel.ts +12 -0
  38. package/openapi/gen/models/generationModelDefaultReasoningEffort.ts +2 -0
  39. package/openapi/gen/models/generationModelId.ts +2 -0
  40. package/openapi/gen/models/index.ts +8 -0
  41. package/openapi/gen/models/invalidFieldSchema.ts +6 -0
  42. package/openapi/gen/models/listGenerationModels200.ts +7 -0
  43. package/openapi/gen/models/listGenerationModels200DefaultGenerationModelId.ts +2 -0
  44. package/openapi/gen/models/pipelineGraphSnapshot.ts +2 -0
  45. package/package.json +2 -2
  46. package/skills/catalog.json +16 -12
  47. package/skills/toloka-build/SKILL.md +44 -58
  48. package/skills/toloka-build/references/human-work.md +156 -0
  49. package/skills/toloka-build/references/local-setup.md +136 -0
  50. package/skills/toloka-build/references/node-checks.md +11 -0
  51. package/skills/toloka-build/references/node-semantics.md +22 -213
  52. package/skills/toloka-build/references/task-ui.md +61 -0
  53. package/skills/toloka-build/references/workflow-and-cli.md +34 -228
  54. package/skills/toloka-fine-tuning/SKILL.md +1 -1
  55. package/skills/toloka-get-started/SKILL.md +1 -1
  56. package/skills/toloka-gisting/SKILL.md +1 -1
  57. package/src/authoring/index.ts +26 -7
  58. package/src/checks/task-ui-typecheck.ts +3 -3
  59. package/src/cli/analytics.ts +1 -0
  60. package/src/cli/check.ts +83 -12
  61. package/src/cli/commands/generation-model.ts +18 -0
  62. package/src/cli/commands/pipeline.ts +1 -1
  63. package/src/cli/commands/source.ts +14 -3
  64. package/src/cli/data.ts +5 -2
  65. package/src/cli/format-error.ts +3 -0
  66. package/src/cli/generation-models.ts +46 -0
  67. package/src/cli/program.ts +2 -0
  68. package/src/cli/project.ts +288 -138
  69. package/src/cli/trainings.ts +12 -0
  70. package/src/cli/ui.ts +3 -1
  71. package/src/client.ts +5 -0
  72. package/src/graph-rules.json +11 -0
  73. package/src/importer.ts +46 -40
  74. package/src/index.ts +4 -0
  75. package/src/transport.ts +23 -5
  76. 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: string;
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 generationCheckNames: readonly string[] = [
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
- if (checks === undefined) return undefined;
287
- const normalized = { ...checks };
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 normalizedChecks = normalizeImportedChecks(checks, generationCheckNames);
311
+ const authoredChecks = withoutEmptyChecks(checks);
327
312
  return {
328
313
  ...settings,
329
- ...(normalizedChecks === undefined ? {} : { checks: normalizedChecks }),
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 normalizedChecks = normalizeImportedChecks(checks, quorumCheckNames);
343
+ const authoredChecks = withoutEmptyChecks(checks);
359
344
  return {
360
345
  ...settings,
361
- ...(normalizedChecks === undefined ? {} : { checks: normalizedChecks }),
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)}${formatSource(fieldDefinition.jsonSchema, 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
- return `import { field } from '@toloka-platform/sdk';\n\n${declarations.map(({ source }) => source).join("\n\n")}\n`;
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(graph: PipelineGraphV1, fieldBindings: ReadonlyMap<string, string>): string {
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
- name: fieldDefinition.name,
469
- jsonSchema: fieldDefinition.jsonSchema,
470
- options: { id: fieldDefinition.id },
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
- return `${imports.join("\n")}\n\nexport default definePipeline({\n dataset: ${formatSource(dataset)},\n fields: ${formatList(fieldReferences, 1)},\n nodes: ${formatList(nodeReferences, 1)},\n connections: ${formatList(connectionReferences, 1)}\n});\n`;
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
- { path: "fields.ts", source: fieldSource(graph, fieldBindings) },
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 response = await fetchImplementation(
91
- resolveRequestUrl(url, baseUrl),
92
- prepareRequestInit(requestOptions, headers),
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'