diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/processItem.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/processItem.ts index 413343e048e..e2b2b0b0bbb 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/processItem.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/processItem.ts @@ -26,8 +26,13 @@ export async function processItem( systemPromptTemplate?: string; }; + const escapedTemplate = (options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE).replace( + /[{}]/g, + (match) => match + match, + ); + const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate( - `${options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE} + `${escapedTemplate} {format_instructions}`, ); diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/processItem.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/processItem.test.ts index 9f4328a347b..474ce11e6f9 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/processItem.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/test/processItem.test.ts @@ -116,6 +116,28 @@ describe('processItem', () => { expect(result).toEqual({ name: 'John', age: 30 }); }); + it('should handle curly braces in custom system prompt template', async () => { + const customTemplate = 'Extract JSON like this: {"name": "value"} from the text.'; + const mockExecuteFunctions = { + getNodeParameter: (param: string) => { + if (param === 'text') return 'John is 30 years old'; + if (param === 'options') return { systemPromptTemplate: customTemplate }; + return undefined; + }, + getNode: () => ({ typeVersion: 1.1 }), + }; + + const llm = new FakeLLM({ response: formatFakeLlmResponse({ name: 'John', age: 30 }) }); + const parser = OutputFixingParser.fromLLM( + llm, + StructuredOutputParser.fromZodSchema(makeZodSchemaFromAttributes(mockPersonAttributes)), + ); + + const result = await processItem(mockExecuteFunctions as any, 0, llm, parser); + + expect(result).toEqual({ name: 'John', age: 30 }); + }); + it('should handle retries when LLM returns invalid data', async () => { const mockExecuteFunctions = { getNodeParameter: (param: string) => { diff --git a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts index 73e64d737ff..02bbe3149f9 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts @@ -217,8 +217,12 @@ export class SentimentAnalysis implements INodeType { ? OutputFixingParser.fromLLM(llm, structuredParser) : structuredParser; + const escapedTemplate = (options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE) + .replace(/[{}]/g, (match) => match + match) + .replaceAll('{{categories}}', '{categories}'); + const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate( - `${options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE} + `${escapedTemplate} {format_instructions}`, ); @@ -357,8 +361,12 @@ export class SentimentAnalysis implements INodeType { ? OutputFixingParser.fromLLM(llm, structuredParser) : structuredParser; + const escapedTemplate = (options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE) + .replace(/[{}]/g, (match) => match + match) + .replaceAll('{{categories}}', '{categories}'); + const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate( - `${options.systemPromptTemplate ?? DEFAULT_SYSTEM_PROMPT_TEMPLATE} + `${escapedTemplate} {format_instructions}`, ); diff --git a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/processItem.ts b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/processItem.ts index a34bac0f6ac..d77729c6d1e 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/processItem.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/processItem.ts @@ -36,8 +36,12 @@ export async function processItem( itemIndex, SYSTEM_PROMPT_TEMPLATE, ) as string; + const escapedTemplate = (systemPromptTemplateOpt ?? SYSTEM_PROMPT_TEMPLATE) + .replace(/[{}]/g, (match) => match + match) + .replaceAll('{{categories}}', '{categories}'); + const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate( - `${systemPromptTemplateOpt ?? SYSTEM_PROMPT_TEMPLATE} + `${escapedTemplate} {format_instructions} ${multiClassPrompt} ${fallbackPrompt}`, diff --git a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/test/processItem.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/test/processItem.test.ts index 5078accf00a..02188081bd1 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/test/processItem.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/test/processItem.test.ts @@ -1,4 +1,4 @@ -import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts'; import { FakeChatModel } from '@langchain/core/utils/testing'; import { mock } from 'jest-mock-extended'; import type { IExecuteFunctions } from 'n8n-workflow'; @@ -102,6 +102,50 @@ describe('processItem', () => { expect(tracing.getTracingConfig).toHaveBeenCalledWith(mockContext); }); + it('should escape curly braces in custom system prompt template', async () => { + const templateWithBraces = 'Classify using format {"category": "value"}: {categories}'; + + mockContext.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => { + if (param === 'inputText') return 'Test input'; + if (param === 'options.systemPromptTemplate') return templateWithBraces; + return defaultValue; + }); + + const mockParser = { + getFormatInstructions: () => '[format instructions]', + }; + + const mockChain = { + invoke: jest.fn().mockResolvedValue({ category: 'test' }), + }; + + const mockPipe = jest.fn().mockReturnValue({ + pipe: jest.fn().mockReturnValue({ + withConfig: jest.fn().mockReturnValue(mockChain), + }), + }); + + jest.mocked(ChatPromptTemplate.fromMessages).mockReturnValue({ pipe: mockPipe } as any); + + await processItem( + mockContext, + 0, + { json: {} }, + fakeLLM, + mockParser as any, + [{ category: 'test', description: 'test category' }], + 'multi class prompt', + undefined, + ); + + expect(SystemMessagePromptTemplate.fromTemplate).toHaveBeenCalledWith( + expect.stringContaining('{{"category": "value"}}'), + ); + expect(SystemMessagePromptTemplate.fromTemplate).toHaveBeenCalledWith( + expect.stringContaining('{categories}'), + ); + }); + it('should handle fallback prompt', async () => { const mockParser = { getFormatInstructions: () => '[format instructions]',