Infer array current item schema (#15115)

This PR allows to infer the schema of the current item of an iterator
step:
- iterator step receive a variable
- added an util that navigate to the array in schema -
navigateOutputSchemaProperty
- use the array value in schema to generate a new schema - used the
existing getFunctionOutputSchema that I renamed and moved to
twenty-shared

Also cleaned a bit the existing schema for AI.

Before


https://github.com/user-attachments/assets/9767fc89-3524-4bfb-b1ab-8abe92084767

After


https://github.com/user-attachments/assets/3650c1d2-14f2-44f9-b10c-e649fe04128d
This commit is contained in:
Thomas Trompette
2025-10-15 18:15:08 +02:00
committed by GitHub
parent b16ab1b7c9
commit d3f3f991a5
42 changed files with 774 additions and 170 deletions
@@ -0,0 +1,106 @@
import { buildOutputSchemaFromValue } from '../buildOutputSchemaFromValue';
describe('buildOutputSchemaFromValue', () => {
it('should compute outputSchema properly for mixed types', () => {
const testResult = {
a: null,
b: 'b',
c: { cc: 1 },
d: true,
e: [1, 2, 3],
};
const expectedOutputSchema = {
a: {
isLeaf: true,
type: 'unknown',
value: null,
label: 'a',
},
b: {
isLeaf: true,
type: 'string',
value: 'b',
label: 'b',
},
c: {
isLeaf: false,
type: 'object',
label: 'c',
value: {
cc: {
isLeaf: true,
type: 'number',
value: 1,
label: 'cc',
},
},
},
d: {
isLeaf: true,
type: 'boolean',
value: true,
label: 'd',
},
e: {
isLeaf: true,
type: 'array',
value: [1, 2, 3],
label: 'e',
},
};
expect(buildOutputSchemaFromValue(testResult)).toEqual(
expectedOutputSchema,
);
});
it('should handle nested objects', () => {
const testResult = {
user: {
name: 'John',
address: {
street: '123 Main St',
city: 'New York',
},
},
};
const result = buildOutputSchemaFromValue(testResult);
expect(result.user).toEqual({
isLeaf: false,
type: 'object',
label: 'user',
value: {
name: {
isLeaf: true,
type: 'string',
value: 'John',
label: 'name',
},
address: {
isLeaf: false,
type: 'object',
label: 'address',
value: {
street: {
isLeaf: true,
type: 'string',
value: '123 Main St',
label: 'street',
},
city: {
isLeaf: true,
type: 'string',
value: 'New York',
label: 'city',
},
},
},
},
});
});
it('should return empty object for empty input', () => {
expect(buildOutputSchemaFromValue({})).toEqual({});
});
});
@@ -0,0 +1,123 @@
import { type BaseOutputSchemaV2 } from '../../types/base-output-schema.type';
import { navigateOutputSchemaProperty } from '../navigateOutputSchemaProperty';
describe('navigateOutputSchemaProperty', () => {
const testSchema: BaseOutputSchemaV2 = {
result: {
isLeaf: false,
type: 'object',
label: 'result',
value: {
items: {
isLeaf: true,
type: 'array',
label: 'items',
value: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
},
metadata: {
isLeaf: false,
type: 'object',
label: 'metadata',
value: {
count: {
isLeaf: true,
type: 'number',
label: 'count',
value: 2,
},
},
},
},
},
name: {
isLeaf: true,
type: 'string',
label: 'name',
value: 'Test',
},
};
it('should navigate to direct property', () => {
const result = navigateOutputSchemaProperty({
schema: testSchema,
propertyPath: ['name'],
});
expect(result).toEqual({
isLeaf: true,
type: 'string',
label: 'name',
value: 'Test',
});
});
it('should navigate to nested property', () => {
const result = navigateOutputSchemaProperty({
schema: testSchema,
propertyPath: ['result', 'items'],
});
expect(result).toEqual({
isLeaf: true,
type: 'array',
label: 'items',
value: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
});
});
it('should navigate to deeply nested property', () => {
const result = navigateOutputSchemaProperty({
schema: testSchema,
propertyPath: ['result', 'metadata', 'count'],
});
expect(result).toEqual({
isLeaf: true,
type: 'number',
label: 'count',
value: 2,
});
});
it('should return undefined for missing property', () => {
const result = navigateOutputSchemaProperty({
schema: testSchema,
propertyPath: ['nonexistent'],
});
expect(result).toBeUndefined();
});
it('should return undefined for missing nested property', () => {
const result = navigateOutputSchemaProperty({
schema: testSchema,
propertyPath: ['result', 'nonexistent'],
});
expect(result).toBeUndefined();
});
it('should return undefined for empty property path', () => {
const result = navigateOutputSchemaProperty({
schema: testSchema,
propertyPath: [],
});
expect(result).toBeUndefined();
});
it('should return undefined when trying to navigate through a leaf', () => {
const result = navigateOutputSchemaProperty({
schema: testSchema,
propertyPath: ['name', 'invalid'],
});
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,53 @@
import { isDefined } from '@/utils';
import {
type BaseOutputSchemaV2,
type LeafType,
} from '@/workflow/workflow-schema/types/base-output-schema.type';
import { isObject } from '@sniptt/guards';
const getValueType = (value: any): LeafType => {
if (!isDefined(value) || value === null) {
return 'unknown';
}
if (typeof value === 'string') {
return 'string';
}
if (typeof value === 'number') {
return 'number';
}
if (typeof value === 'boolean') {
return 'boolean';
}
if (Array.isArray(value)) {
return 'array';
}
return 'unknown';
};
export const buildOutputSchemaFromValue = (
testResult: object,
): BaseOutputSchemaV2 => {
return testResult
? Object.entries(testResult).reduce(
(acc: BaseOutputSchemaV2, [key, value]) => {
if (isObject(value) && !Array.isArray(value)) {
acc[key] = {
isLeaf: false,
type: 'object',
label: key,
value: buildOutputSchemaFromValue(value),
};
} else {
acc[key] = {
isLeaf: true,
value,
type: getValueType(value),
label: key,
};
}
return acc;
},
{},
)
: {};
};
@@ -0,0 +1,39 @@
import { isDefined } from '@/utils';
import {
type BaseOutputSchemaV2,
type Leaf,
type Node,
} from '@/workflow/workflow-schema/types/base-output-schema.type';
export const navigateOutputSchemaProperty = ({
schema,
propertyPath,
}: {
schema: BaseOutputSchemaV2;
propertyPath: string[];
}): Leaf | Node | undefined => {
if (propertyPath.length === 0) {
return undefined;
}
let currentSchema: BaseOutputSchemaV2 = schema;
let currentField: Leaf | Node | undefined;
for (const pathSegment of propertyPath) {
currentField = currentSchema[pathSegment];
if (!isDefined(currentField)) {
return undefined;
}
if (currentField.isLeaf) {
const isLastSegment =
pathSegment === propertyPath[propertyPath.length - 1];
return isLastSegment ? currentField : undefined;
}
currentSchema = currentField.value;
}
return currentField;
};