mirror of
https://github.com/n8n-io/n8n.git
synced 2026-11-04 23:35:45 +08:00
fix(core): Escape curly braces in LangChain prompt templates to prevent parsing errors (#25821)
Co-authored-by: aalises <aalises@users.noreply.github.com>
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
|
||||
|
||||
+22
@@ -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) => {
|
||||
|
||||
+10
-2
@@ -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}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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]',
|
||||
|
||||
Reference in New Issue
Block a user