Skip to content

Commit 7711e4b

Browse files
authored
feat(Structured Output Parser Node): Mark all parameters as required for schemas generated from JSON example (#15935)
1 parent 6cf0720 commit 7711e4b

File tree

10 files changed

+980
-21
lines changed

10 files changed

+980
-21
lines changed

packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import type {
1111
} from 'n8n-workflow';
1212
import type { z } from 'zod';
1313

14-
import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions';
15-
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
14+
import {
15+
buildJsonSchemaExampleNotice,
16+
inputSchemaField,
17+
jsonSchemaExampleField,
18+
schemaTypeField,
19+
} from '@utils/descriptions';
20+
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
1621
import { getBatchingOptionFields } from '@utils/sharedFields';
1722

1823
import { SYSTEM_PROMPT_TEMPLATE } from './constants';
@@ -27,7 +32,8 @@ export class InformationExtractor implements INodeType {
2732
icon: 'fa:project-diagram',
2833
iconColor: 'black',
2934
group: ['transform'],
30-
version: [1, 1.1],
35+
version: [1, 1.1, 1.2],
36+
defaultVersion: 1.2,
3137
description: 'Extract information from text in a structured format',
3238
codex: {
3339
alias: ['NER', 'parse', 'parsing', 'JSON', 'data extraction', 'structured'],
@@ -88,6 +94,11 @@ export class InformationExtractor implements INodeType {
8894
"cities": ["Los Angeles", "San Francisco", "San Diego"]
8995
}`,
9096
},
97+
buildJsonSchemaExampleNotice({
98+
showExtraProps: {
99+
'@version': [{ _cnd: { gte: 1.2 } }],
100+
},
101+
}),
91102
{
92103
...inputSchemaField,
93104
default: `{
@@ -242,7 +253,10 @@ export class InformationExtractor implements INodeType {
242253

243254
if (schemaType === 'fromJson') {
244255
const jsonExample = this.getNodeParameter('jsonSchemaExample', 0, '') as string;
245-
jsonSchema = generateSchema(jsonExample);
256+
// Enforce all fields to be required in the generated schema if the node version is 1.2 or higher
257+
const jsonExampleAllFieldsRequired = this.getNode().typeVersion >= 1.2;
258+
259+
jsonSchema = generateSchemaFromExample(jsonExample, jsonExampleAllFieldsRequired);
246260
} else {
247261
const inputSchema = this.getNodeParameter('inputSchema', 0, '') as string;
248262
jsonSchema = jsonParse<JSONSchema7>(inputSchema);

packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/InformationExtraction.node.test.ts

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
22
import { FakeListChatModel } from '@langchain/core/utils/testing';
3+
import { mock } from 'jest-mock-extended';
34
import get from 'lodash/get';
4-
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
5+
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
56

67
import { makeZodSchemaFromAttributes } from '../helpers';
78
import { InformationExtractor } from '../InformationExtractor.node';
@@ -88,6 +89,208 @@ describe('InformationExtractor', () => {
8889
});
8990
});
9091

92+
describe('Single Item Processing with JSON Schema from Example', () => {
93+
it('should extract information using JSON schema from example - version 1.2 (required fields)', async () => {
94+
const node = new InformationExtractor();
95+
const inputData = [
96+
{
97+
json: { text: 'John lives in California and has visited Los Angeles and San Francisco' },
98+
},
99+
];
100+
101+
const mockExecuteFunctions = createExecuteFunctionsMock(
102+
{
103+
text: 'John lives in California and has visited Los Angeles and San Francisco',
104+
schemaType: 'fromJson',
105+
jsonSchemaExample: JSON.stringify({
106+
state: 'California',
107+
cities: ['Los Angeles', 'San Francisco'],
108+
}),
109+
options: {
110+
systemPromptTemplate: '',
111+
},
112+
},
113+
new FakeListChatModel({
114+
responses: [
115+
formatFakeLlmResponse({
116+
state: 'California',
117+
cities: ['Los Angeles', 'San Francisco'],
118+
}),
119+
],
120+
}),
121+
inputData,
122+
);
123+
124+
// Mock version 1.2 to test required fields behavior
125+
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.2 });
126+
127+
const response = await node.execute.call(mockExecuteFunctions);
128+
129+
expect(response).toEqual([
130+
[
131+
{
132+
json: {
133+
output: {
134+
state: 'California',
135+
cities: ['Los Angeles', 'San Francisco'],
136+
},
137+
},
138+
},
139+
],
140+
]);
141+
});
142+
143+
it('should extract information using JSON schema from example - version 1.1 (optional fields)', async () => {
144+
const node = new InformationExtractor();
145+
const inputData = [{ json: { text: 'John lives in California' } }];
146+
147+
const mockExecuteFunctions = createExecuteFunctionsMock(
148+
{
149+
text: 'John lives in California',
150+
schemaType: 'fromJson',
151+
jsonSchemaExample: JSON.stringify({
152+
state: 'California',
153+
cities: ['Los Angeles', 'San Francisco'],
154+
}),
155+
options: {
156+
systemPromptTemplate: '',
157+
},
158+
},
159+
new FakeListChatModel({
160+
responses: [
161+
formatFakeLlmResponse({
162+
state: 'California',
163+
// cities field missing - should be allowed in v1.1
164+
}),
165+
],
166+
}),
167+
inputData,
168+
);
169+
170+
// Mock version 1.1 to test optional fields behavior
171+
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.1 });
172+
173+
const response = await node.execute.call(mockExecuteFunctions);
174+
175+
expect(response).toEqual([
176+
[
177+
{
178+
json: {
179+
output: {
180+
state: 'California',
181+
},
182+
},
183+
},
184+
],
185+
]);
186+
});
187+
188+
it('should throw error for incomplete model output in version 1.2 (required fields)', async () => {
189+
const node = new InformationExtractor();
190+
const inputData = [{ json: { text: 'John lives in California' } }];
191+
192+
const mockExecuteFunctions = createExecuteFunctionsMock(
193+
{
194+
text: 'John lives in California',
195+
schemaType: 'fromJson',
196+
jsonSchemaExample: JSON.stringify({
197+
state: 'California',
198+
cities: ['Los Angeles', 'San Francisco'],
199+
zipCode: '90210',
200+
}),
201+
options: {
202+
systemPromptTemplate: '',
203+
},
204+
},
205+
new FakeListChatModel({
206+
responses: [
207+
formatFakeLlmResponse({
208+
state: 'California',
209+
// Missing cities and zipCode - should fail in v1.2 since all fields are required
210+
}),
211+
],
212+
}),
213+
inputData,
214+
);
215+
216+
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.2 });
217+
218+
await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow();
219+
});
220+
221+
it('should extract information using complex nested JSON schema from example', async () => {
222+
const node = new InformationExtractor();
223+
const inputData = [
224+
{
225+
json: {
226+
text: 'John Doe works at Acme Corp as a Software Engineer with 5 years experience',
227+
},
228+
},
229+
];
230+
231+
const complexSchema = {
232+
person: {
233+
name: 'John Doe',
234+
company: {
235+
name: 'Acme Corp',
236+
position: 'Software Engineer',
237+
},
238+
},
239+
experience: {
240+
years: 5,
241+
skills: ['JavaScript', 'TypeScript'],
242+
},
243+
};
244+
245+
const mockExecuteFunctions = createExecuteFunctionsMock(
246+
{
247+
text: 'John Doe works at Acme Corp as a Software Engineer with 5 years experience',
248+
schemaType: 'fromJson',
249+
jsonSchemaExample: JSON.stringify(complexSchema),
250+
options: {
251+
systemPromptTemplate: '',
252+
},
253+
},
254+
new FakeListChatModel({
255+
responses: [
256+
formatFakeLlmResponse({
257+
person: {
258+
name: 'John Doe',
259+
company: {
260+
name: 'Acme Corp',
261+
position: 'Software Engineer',
262+
},
263+
},
264+
experience: {
265+
years: 5,
266+
skills: ['JavaScript', 'TypeScript'],
267+
},
268+
}),
269+
],
270+
}),
271+
inputData,
272+
);
273+
274+
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.2 });
275+
276+
const response = await node.execute.call(mockExecuteFunctions);
277+
278+
expect(response[0][0].json.output).toMatchObject({
279+
person: {
280+
name: 'John Doe',
281+
company: {
282+
name: 'Acme Corp',
283+
position: 'Software Engineer',
284+
},
285+
},
286+
experience: {
287+
years: 5,
288+
skills: expect.arrayContaining(['JavaScript', 'TypeScript']),
289+
},
290+
});
291+
});
292+
});
293+
91294
describe('Batch Processing', () => {
92295
it('should process multiple items in batches', async () => {
93296
const node = new InformationExtractor();

packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ import {
1212
} from 'n8n-workflow';
1313
import type { z } from 'zod';
1414

15-
import { inputSchemaField, jsonSchemaExampleField, schemaTypeField } from '@utils/descriptions';
15+
import {
16+
buildJsonSchemaExampleNotice,
17+
inputSchemaField,
18+
jsonSchemaExampleField,
19+
schemaTypeField,
20+
} from '@utils/descriptions';
1621
import {
1722
N8nOutputFixingParser,
1823
N8nStructuredOutputParser,
1924
} from '@utils/output_parsers/N8nOutputParser';
20-
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
25+
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
2126
import { getConnectionHintNoticeField } from '@utils/sharedFields';
2227

2328
import { NAIVE_FIX_PROMPT } from './prompt';
@@ -29,8 +34,8 @@ export class OutputParserStructured implements INodeType {
2934
icon: 'fa:code',
3035
iconColor: 'black',
3136
group: ['transform'],
32-
version: [1, 1.1, 1.2],
33-
defaultVersion: 1.2,
37+
version: [1, 1.1, 1.2, 1.3],
38+
defaultVersion: 1.3,
3439
description: 'Return data in a defined JSON format',
3540
defaults: {
3641
name: 'Structured Output Parser',
@@ -74,6 +79,11 @@ export class OutputParserStructured implements INodeType {
7479
"cities": ["Los Angeles", "San Francisco", "San Diego"]
7580
}`,
7681
},
82+
buildJsonSchemaExampleNotice({
83+
showExtraProps: {
84+
'@version': [{ _cnd: { gte: 1.3 } }],
85+
},
86+
}),
7787
{
7888
...inputSchemaField,
7989
default: `{
@@ -181,14 +191,19 @@ export class OutputParserStructured implements INodeType {
181191

182192
let inputSchema: string;
183193

194+
// Enforce all fields to be required in the generated schema if the node version is 1.3 or higher
195+
const jsonExampleAllFieldsRequired = this.getNode().typeVersion >= 1.3;
196+
184197
if (this.getNode().typeVersion <= 1.1) {
185198
inputSchema = this.getNodeParameter('jsonSchema', itemIndex, '') as string;
186199
} else {
187200
inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
188201
}
189202

190203
const jsonSchema =
191-
schemaType === 'fromJson' ? generateSchema(jsonExample) : jsonParse<JSONSchema7>(inputSchema);
204+
schemaType === 'fromJson'
205+
? generateSchemaFromExample(jsonExample, jsonExampleAllFieldsRequired)
206+
: jsonParse<JSONSchema7>(inputSchema);
192207

193208
const zodSchema = convertJsonSchemaToZod<z.ZodSchema<object>>(jsonSchema);
194209
const nodeVersion = this.getNode().typeVersion;

0 commit comments

Comments
 (0)