first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
import { NodeConnectionTypes, parseErrorMetadata, sleep } from 'n8n-workflow';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
promptTypeOptions,
|
||||
promptTypeOptionsDeprecated,
|
||||
textFromGuardrailsNode,
|
||||
textFromPreviousNode,
|
||||
} from '@utils/descriptions';
|
||||
import { getBatchingOptionFields, getTemplateNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import { INPUT_TEMPLATE_KEY, LEGACY_INPUT_TEMPLATE_KEY, systemPromptOption } from './constants';
|
||||
import { processItem } from './processItem';
|
||||
|
||||
export class ChainRetrievalQa implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Question and Answer Chain',
|
||||
name: 'chainRetrievalQa',
|
||||
icon: 'fa:link',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7],
|
||||
description: 'Answer questions about retrieved documents',
|
||||
defaults: {
|
||||
name: 'Question and Answer Chain',
|
||||
color: '#909298',
|
||||
},
|
||||
codex: {
|
||||
alias: ['LangChain'],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Chains', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.chainretrievalqa/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [
|
||||
NodeConnectionTypes.Main,
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Retriever',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiRetriever,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_retriever: { required: true },
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
properties: [
|
||||
getTemplateNoticeField(1960),
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.input }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chat_input }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chatInput }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...promptTypeOptionsDeprecated,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { lte: 1.2 } }, { _cnd: { gte: 1.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...promptTypeOptions,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...textFromGuardrailsNode,
|
||||
displayOptions: {
|
||||
show: { promptType: ['guardrails'], '@version': [{ _cnd: { gte: 1.4 } }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.4 } }] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt (User Message)',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
promptType: ['define'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
...systemPromptOption,
|
||||
description: `Template string used for the system prompt. This should include the variable \`{context}\` for the provided context. For text completion models, you should also include the variable \`{${LEGACY_INPUT_TEMPLATE_KEY}}\` for the user’s query.`,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 1.5 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...systemPromptOption,
|
||||
description: `Template string used for the system prompt. This should include the variable \`{context}\` for the provided context. For text completion models, you should also include the variable \`{${INPUT_TEMPLATE_KEY}}\` for the user’s query.`,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.5 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
getBatchingOptionFields({
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.6 } }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
this.logger.debug('Executing Retrieval QA Chain');
|
||||
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const batchSize = this.getNodeParameter('options.batching.batchSize', 0, 5) as number;
|
||||
const delayBetweenBatches = this.getNodeParameter(
|
||||
'options.batching.delayBetweenBatches',
|
||||
0,
|
||||
0,
|
||||
) as number;
|
||||
|
||||
if (this.getNode().typeVersion >= 1.6 && batchSize >= 1) {
|
||||
// Run in batches
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(async (_item, batchItemIndex) => {
|
||||
return await processItem(this, i + batchItemIndex);
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
batchResults.forEach((response, index) => {
|
||||
if (response.status === 'rejected') {
|
||||
const error = response.reason;
|
||||
if (this.continueOnFail()) {
|
||||
const metadata = parseErrorMetadata(error);
|
||||
returnData.push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: index },
|
||||
metadata,
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const output = response.value;
|
||||
const answer = output.answer as string;
|
||||
if (this.getNode().typeVersion >= 1.5) {
|
||||
returnData.push({ json: { response: answer } });
|
||||
} else {
|
||||
// Legacy format for versions 1.4 and below is { text: string }
|
||||
returnData.push({ json: { response: { text: answer } } });
|
||||
}
|
||||
});
|
||||
|
||||
// Add delay between batches if not the last batch
|
||||
if (i + batchSize < items.length && delayBetweenBatches > 0) {
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Run for each item
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
try {
|
||||
const response = await processItem(this, itemIndex);
|
||||
const answer = response.answer as string;
|
||||
if (this.getNode().typeVersion >= 1.5) {
|
||||
returnData.push({ json: { response: answer } });
|
||||
} else {
|
||||
// Legacy format for versions 1.4 and below is { text: string }
|
||||
returnData.push({ json: { response: { text: answer } } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const metadata = parseErrorMetadata(error);
|
||||
returnData.push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: itemIndex },
|
||||
metadata,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const SYSTEM_PROMPT_TEMPLATE = `You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question.
|
||||
If you don't know the answer, just say that you don't know, don't try to make up an answer.
|
||||
----------------
|
||||
Context: {context}`;
|
||||
|
||||
// Due to the refactoring in version 1.5, the variable name {question} needed to be changed to {input} in the prompt template.
|
||||
export const LEGACY_INPUT_TEMPLATE_KEY = 'question';
|
||||
export const INPUT_TEMPLATE_KEY = 'input';
|
||||
|
||||
export const systemPromptOption: INodeProperties = {
|
||||
displayName: 'System Prompt Template',
|
||||
name: 'systemPromptTemplate',
|
||||
type: 'string',
|
||||
default: SYSTEM_PROMPT_TEMPLATE,
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
PromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import type { BaseRetriever } from '@langchain/core/retrievers';
|
||||
import { createStuffDocumentsChain } from '@langchain/classic/chains/combine_documents';
|
||||
import { createRetrievalChain } from '@langchain/classic/chains/retrieval';
|
||||
import { type IExecuteFunctions, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { isChatInstance } from '@n8n/ai-utilities';
|
||||
import { getPromptInputByType } from '@utils/helpers';
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
import { INPUT_TEMPLATE_KEY, LEGACY_INPUT_TEMPLATE_KEY, SYSTEM_PROMPT_TEMPLATE } from './constants';
|
||||
|
||||
export const processItem = async (
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const model = (await ctx.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
const retriever = (await ctx.getInputConnectionData(
|
||||
NodeConnectionTypes.AiRetriever,
|
||||
0,
|
||||
)) as BaseRetriever;
|
||||
|
||||
let query;
|
||||
|
||||
if (ctx.getNode().typeVersion <= 1.2) {
|
||||
query = ctx.getNodeParameter('query', itemIndex) as string;
|
||||
} else {
|
||||
query = getPromptInputByType({
|
||||
ctx,
|
||||
i: itemIndex,
|
||||
inputKey: 'text',
|
||||
promptTypeKey: 'promptType',
|
||||
});
|
||||
}
|
||||
|
||||
if (query === undefined) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'The ‘query‘ parameter is empty.');
|
||||
}
|
||||
|
||||
const options = ctx.getNodeParameter('options', itemIndex, {}) as {
|
||||
systemPromptTemplate?: string;
|
||||
};
|
||||
|
||||
let templateText = options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE;
|
||||
|
||||
// Replace legacy input template key for versions 1.4 and below
|
||||
if (ctx.getNode().typeVersion < 1.5) {
|
||||
templateText = templateText.replace(
|
||||
`{${LEGACY_INPUT_TEMPLATE_KEY}}`,
|
||||
`{${INPUT_TEMPLATE_KEY}}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create prompt template based on model type and user configuration
|
||||
let promptTemplate;
|
||||
if (isChatInstance(model)) {
|
||||
// For chat models, create a chat prompt template with system and human messages
|
||||
const messages = [
|
||||
SystemMessagePromptTemplate.fromTemplate(templateText),
|
||||
HumanMessagePromptTemplate.fromTemplate('{input}'),
|
||||
];
|
||||
promptTemplate = ChatPromptTemplate.fromMessages(messages);
|
||||
} else {
|
||||
// For non-chat models, create a text prompt template with Question/Answer format
|
||||
const questionSuffix =
|
||||
options.systemPromptTemplate === undefined ? '\n\nQuestion: {input}\nAnswer:' : '';
|
||||
|
||||
promptTemplate = new PromptTemplate({
|
||||
template: templateText + questionSuffix,
|
||||
inputVariables: ['context', 'input'],
|
||||
});
|
||||
}
|
||||
|
||||
// Create the document chain that combines the retrieved documents
|
||||
const combineDocsChain = await createStuffDocumentsChain({
|
||||
llm: model,
|
||||
prompt: promptTemplate,
|
||||
});
|
||||
|
||||
// Create the retrieval chain that handles the retrieval and then passes to the combine docs chain
|
||||
const retrievalChain = await createRetrievalChain({
|
||||
combineDocsChain,
|
||||
retriever,
|
||||
});
|
||||
|
||||
// Execute the chain with tracing config
|
||||
const tracingConfig = getTracingConfig(ctx);
|
||||
return await retrievalChain
|
||||
.withConfig(tracingConfig)
|
||||
.invoke({ input: query }, { signal: ctx.getExecutionCancelSignal() });
|
||||
};
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
import { Document } from '@langchain/core/documents';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseRetriever } from '@langchain/core/retrievers';
|
||||
import { FakeChatModel, FakeLLM, FakeRetriever } from '@langchain/core/utils/testing';
|
||||
import get from 'lodash/get';
|
||||
import type { IDataObject, IExecuteFunctions, NodeConnectionType } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError, UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { ChainRetrievalQa } from '../ChainRetrievalQa.node';
|
||||
|
||||
const createExecuteFunctionsMock = (
|
||||
parameters: IDataObject,
|
||||
fakeLlm: BaseLanguageModel,
|
||||
fakeRetriever: BaseRetriever,
|
||||
version: number,
|
||||
) => {
|
||||
return {
|
||||
getExecutionCancelSignal() {
|
||||
return new AbortController().signal;
|
||||
},
|
||||
getNodeParameter(parameter: string) {
|
||||
return get(parameters, parameter);
|
||||
},
|
||||
getNode() {
|
||||
return {
|
||||
typeVersion: version,
|
||||
};
|
||||
},
|
||||
getInputConnectionData(type: NodeConnectionType) {
|
||||
if (type === NodeConnectionTypes.AiLanguageModel) {
|
||||
return fakeLlm;
|
||||
}
|
||||
if (type === NodeConnectionTypes.AiRetriever) {
|
||||
return fakeRetriever;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getInputData() {
|
||||
return [{ json: {} }];
|
||||
},
|
||||
getWorkflow() {
|
||||
return {
|
||||
name: 'Test Workflow',
|
||||
};
|
||||
},
|
||||
getExecutionId() {
|
||||
return 'test_execution_id';
|
||||
},
|
||||
continueOnFail() {
|
||||
return false;
|
||||
},
|
||||
logger: { debug: jest.fn() },
|
||||
} as unknown as IExecuteFunctions;
|
||||
};
|
||||
|
||||
describe('ChainRetrievalQa', () => {
|
||||
let node: ChainRetrievalQa;
|
||||
const testDocs = [
|
||||
new Document({
|
||||
pageContent: 'The capital of France is Paris. It is known for the Eiffel Tower.',
|
||||
}),
|
||||
new Document({
|
||||
pageContent:
|
||||
'Paris is the largest city in France with a population of over 2 million people.',
|
||||
}),
|
||||
];
|
||||
|
||||
const fakeRetriever = new FakeRetriever({ output: testDocs });
|
||||
|
||||
beforeEach(() => {
|
||||
node = new ChainRetrievalQa();
|
||||
});
|
||||
|
||||
it.each([1.3, 1.4, 1.5, 1.6])(
|
||||
'should process a query using a chat model (version %s)',
|
||||
async (version) => {
|
||||
// Mock a chat model that returns a predefined answer
|
||||
const mockChatModel = new FakeChatModel({});
|
||||
|
||||
const params = {
|
||||
promptType: 'define',
|
||||
text: 'What is the capital of France?',
|
||||
options: {},
|
||||
};
|
||||
|
||||
const result = await node.execute.call(
|
||||
createExecuteFunctionsMock(params, mockChatModel, fakeRetriever, version),
|
||||
);
|
||||
|
||||
// Check that the result contains the expected response (FakeChatModel returns the query as response)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.response).toBeDefined();
|
||||
|
||||
let responseText = result[0][0].json.response;
|
||||
if (version < 1.5 && typeof responseText === 'object') {
|
||||
responseText = (responseText as { text: string }).text;
|
||||
}
|
||||
|
||||
expect(responseText).toContain('You are an assistant for question-answering tasks'); // system prompt
|
||||
expect(responseText).toContain('The capital of France is Paris.'); // context
|
||||
expect(responseText).toContain('What is the capital of France?'); // query
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1.3, 1.4, 1.5, 1.6])(
|
||||
'should process a query using a text completion model (version %s)',
|
||||
async (version) => {
|
||||
// Mock a text completion model that returns a predefined answer
|
||||
const mockTextModel = new FakeLLM({ response: 'Paris is the capital of France.' });
|
||||
|
||||
const modelCallSpy = jest.spyOn(mockTextModel, '_call');
|
||||
|
||||
const params = {
|
||||
promptType: 'define',
|
||||
text: 'What is the capital of France?',
|
||||
options: {},
|
||||
};
|
||||
|
||||
const result = await node.execute.call(
|
||||
createExecuteFunctionsMock(params, mockTextModel, fakeRetriever, version),
|
||||
);
|
||||
|
||||
// Check model was called with the correct query
|
||||
expect(modelCallSpy).toHaveBeenCalled();
|
||||
expect(modelCallSpy.mock.calls[0][0]).toEqual(
|
||||
expect.stringContaining('Question: What is the capital of France?'),
|
||||
);
|
||||
|
||||
// Check that the result contains the expected response
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
|
||||
if (version < 1.5) {
|
||||
expect((result[0][0].json.response as { text: string }).text).toContain(
|
||||
'Paris is the capital of France.',
|
||||
);
|
||||
} else {
|
||||
expect(result[0][0].json).toEqual({
|
||||
response: 'Paris is the capital of France.',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1.3, 1.4, 1.5, 1.6])(
|
||||
'should use a custom system prompt if provided (version %s)',
|
||||
async (version) => {
|
||||
const customSystemPrompt = `You are a geography expert. Use the following context to answer the question.
|
||||
----------------
|
||||
Context: {context}`;
|
||||
|
||||
// The chat model will return a response indicating it received the custom prompt
|
||||
const mockChatModel = new FakeChatModel({});
|
||||
|
||||
const params = {
|
||||
promptType: 'define',
|
||||
text: 'What is the capital of France?',
|
||||
options: {
|
||||
systemPromptTemplate: customSystemPrompt,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await node.execute.call(
|
||||
createExecuteFunctionsMock(params, mockChatModel, fakeRetriever, version),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
if (version < 1.5) {
|
||||
expect((result[0][0].json.response as { text: string }).text).toContain(
|
||||
'You are a geography expert.',
|
||||
);
|
||||
} else {
|
||||
expect(result[0][0].json.response).toContain('You are a geography expert.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1.3, 1.4, 1.5, 1.6])(
|
||||
'should throw an error if the query is undefined (version %s)',
|
||||
async (version) => {
|
||||
const mockChatModel = new FakeChatModel({});
|
||||
|
||||
const params = {
|
||||
promptType: 'define',
|
||||
text: undefined, // undefined query
|
||||
options: {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
node.execute.call(
|
||||
createExecuteFunctionsMock(params, mockChatModel, fakeRetriever, version),
|
||||
),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1.3, 1.4, 1.5, 1.6])(
|
||||
'should add error to json if continueOnFail is true (version %s)',
|
||||
async (version) => {
|
||||
// Create a model that will throw an error
|
||||
class ErrorLLM extends FakeLLM {
|
||||
async _call(): Promise<string> {
|
||||
throw new UnexpectedError('Model error');
|
||||
}
|
||||
}
|
||||
|
||||
const errorModel = new ErrorLLM({});
|
||||
|
||||
const params = {
|
||||
promptType: 'define',
|
||||
text: 'What is the capital of France?',
|
||||
options: {},
|
||||
};
|
||||
|
||||
// Override continueOnFail to return true
|
||||
const execMock = createExecuteFunctionsMock(params, errorModel, fakeRetriever, version);
|
||||
execMock.continueOnFail = () => true;
|
||||
|
||||
const result = await node.execute.call(execMock);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json).toHaveProperty('error');
|
||||
expect(result[0][0].json.error).toContain('Model error');
|
||||
},
|
||||
);
|
||||
|
||||
it('should process items in batches', async () => {
|
||||
const mockChatModel = new FakeLLM({ response: 'Paris is the capital of France.' });
|
||||
const items = [
|
||||
{ json: { input: 'What is the capital of France?' } },
|
||||
{ json: { input: 'What is the capital of France?' } },
|
||||
{ json: { input: 'What is the capital of France?' } },
|
||||
];
|
||||
|
||||
const execMock = createExecuteFunctionsMock(
|
||||
{
|
||||
promptType: 'define',
|
||||
text: '={{ $json.input }}',
|
||||
options: {
|
||||
batching: {
|
||||
batchSize: 2,
|
||||
delayBetweenBatches: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
mockChatModel,
|
||||
fakeRetriever,
|
||||
1.6,
|
||||
);
|
||||
|
||||
execMock.getInputData = () => items;
|
||||
|
||||
const result = await node.execute.call(execMock);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(3);
|
||||
result[0].forEach((item) => {
|
||||
expect(item.json.response).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result[0][0].json.response).toContain('Paris is the capital of France.');
|
||||
expect(result[0][1].json.response).toContain('Paris is the capital of France.');
|
||||
expect(result[0][2].json.response).toContain('Paris is the capital of France.');
|
||||
});
|
||||
|
||||
it('should handle errors in batches with continueOnFail', async () => {
|
||||
class ErrorLLM extends FakeLLM {
|
||||
async _call(): Promise<string> {
|
||||
throw new UnexpectedError('Model error');
|
||||
}
|
||||
}
|
||||
|
||||
const errorModel = new ErrorLLM({});
|
||||
const items = [
|
||||
{ json: { input: 'What is the capital of France?' } },
|
||||
{ json: { input: 'What is the population of Paris?' } },
|
||||
];
|
||||
|
||||
const execMock = createExecuteFunctionsMock(
|
||||
{
|
||||
promptType: 'define',
|
||||
text: '={{ $json.input }}',
|
||||
options: {
|
||||
batching: {
|
||||
batchSize: 2,
|
||||
delayBetweenBatches: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
errorModel,
|
||||
fakeRetriever,
|
||||
1.6,
|
||||
);
|
||||
|
||||
execMock.getInputData = () => items;
|
||||
execMock.continueOnFail = () => true;
|
||||
|
||||
const result = await node.execute.call(execMock);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
result[0].forEach((item) => {
|
||||
expect(item.json.error).toContain('Model error');
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect delay between batches', async () => {
|
||||
const mockChatModel = new FakeChatModel({});
|
||||
const items = [
|
||||
{ json: { input: 'What is the capital of France?' } },
|
||||
{ json: { input: 'What is the population of Paris?' } },
|
||||
{ json: { input: 'What is France known for?' } },
|
||||
];
|
||||
|
||||
const execMock = createExecuteFunctionsMock(
|
||||
{
|
||||
promptType: 'define',
|
||||
text: '={{ $json.input }}',
|
||||
options: {
|
||||
batching: {
|
||||
batchSize: 2,
|
||||
delayBetweenBatches: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
mockChatModel,
|
||||
fakeRetriever,
|
||||
1.6,
|
||||
);
|
||||
|
||||
execMock.getInputData = () => items;
|
||||
|
||||
const startTime = Date.now();
|
||||
await node.execute.call(execMock);
|
||||
const endTime = Date.now();
|
||||
|
||||
// Should take at least 100ms due to delay between batches
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user