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,168 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeConnectionTypes, NodeOperationError, sleep } from 'n8n-workflow';
|
||||
|
||||
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
// Import from centralized module
|
||||
import { formatResponse, getInputs, nodeProperties } from './methods';
|
||||
import { processItem } from './methods/processItem';
|
||||
import {
|
||||
getCustomErrorMessage as getCustomOpenAiErrorMessage,
|
||||
isOpenAiError,
|
||||
} from '../../vendors/OpenAi/helpers/error-handling';
|
||||
|
||||
/**
|
||||
* Basic LLM Chain Node Implementation
|
||||
* Allows connecting to language models with optional structured output parsing
|
||||
*/
|
||||
export class ChainLlm implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Basic LLM Chain',
|
||||
name: 'chainLlm',
|
||||
icon: 'fa:link',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9],
|
||||
description: 'A simple chain to prompt a large language model',
|
||||
defaults: {
|
||||
name: 'Basic LLM 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.chainllm/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
inputs: `={{ ((parameter) => { ${getInputs.toString()}; return getInputs(parameter) })($parameter) }}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_outputParser: {
|
||||
required: false,
|
||||
displayOptions: { show: { hasOutputParser: [true] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
properties: nodeProperties,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main execution method for the node
|
||||
*/
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
this.logger.debug('Executing Basic LLM Chain');
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const outputParser = await getOptionalOutputParser(this);
|
||||
// If the node version is 1.6(and LLM is using `response_format: json_object`) or higher or an output parser is configured,
|
||||
// we unwrap the response and return the object directly as JSON
|
||||
const shouldUnwrapObjects = this.getNode().typeVersion >= 1.6 || !!outputParser;
|
||||
|
||||
const batchSize = this.getNodeParameter('batching.batchSize', 0, 5) as number;
|
||||
const delayBetweenBatches = this.getNodeParameter(
|
||||
'batching.delayBetweenBatches',
|
||||
0,
|
||||
0,
|
||||
) as number;
|
||||
|
||||
if (this.getNode().typeVersion >= 1.7 && batchSize > 1) {
|
||||
// Process items 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((promiseResult, batchItemIndex) => {
|
||||
const itemIndex = i + batchItemIndex;
|
||||
if (promiseResult.status === 'rejected') {
|
||||
const error = promiseResult.reason as Error;
|
||||
// Handle OpenAI specific rate limit errors
|
||||
if (error instanceof NodeApiError && isOpenAiError(error.cause)) {
|
||||
const openAiErrorCode: string | undefined = (error.cause as any).error?.code;
|
||||
if (openAiErrorCode) {
|
||||
const customMessage = getCustomOpenAiErrorMessage(openAiErrorCode);
|
||||
if (customMessage) {
|
||||
error.message = customMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: itemIndex },
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error);
|
||||
}
|
||||
|
||||
const responses = promiseResult.value;
|
||||
responses.forEach((response: unknown) => {
|
||||
returnData.push({
|
||||
json: formatResponse(response, shouldUnwrapObjects),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (i + batchSize < items.length && delayBetweenBatches > 0) {
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Process each input item
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
try {
|
||||
const responses = await processItem(this, itemIndex);
|
||||
|
||||
// Process each response and add to return data
|
||||
responses.forEach((response) => {
|
||||
returnData.push({
|
||||
json: formatResponse(response, shouldUnwrapObjects),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle OpenAI specific rate limit errors
|
||||
if (error instanceof NodeApiError && isOpenAiError(error.cause)) {
|
||||
const openAiErrorCode: string | undefined = (error.cause as any).error?.code;
|
||||
if (openAiErrorCode) {
|
||||
const customMessage = getCustomOpenAiErrorMessage(openAiErrorCode);
|
||||
if (customMessage) {
|
||||
error.message = customMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue on failure if configured
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { Tool } from '@langchain/classic/tools';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseLLMOutputParser } from '@langchain/core/output_parsers';
|
||||
import { JsonOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import type { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { isChatInstance } from '@n8n/ai-utilities';
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
import { createPromptTemplate, getAgentStepsParser } from './promptUtils';
|
||||
import type { ChainExecutionParams } from './types';
|
||||
|
||||
export class NaiveJsonOutputParser<
|
||||
T extends Record<string, any> = Record<string, any>,
|
||||
> extends JsonOutputParser<T> {
|
||||
async parse(text: string): Promise<T> {
|
||||
// First try direct JSON parsing
|
||||
try {
|
||||
const directParsed = JSON.parse(text);
|
||||
return directParsed as T;
|
||||
} catch (e) {
|
||||
// If fails, fall back to JsonOutputParser parser
|
||||
return await super.parse(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if the LLM has a modelKwargs property(OpenAI)
|
||||
*/
|
||||
export function isModelWithResponseFormat(
|
||||
llm: BaseLanguageModel,
|
||||
): llm is BaseLanguageModel & { modelKwargs: { response_format: { type: string } } } {
|
||||
return (
|
||||
'modelKwargs' in llm &&
|
||||
!!llm.modelKwargs &&
|
||||
typeof llm.modelKwargs === 'object' &&
|
||||
'response_format' in llm.modelKwargs
|
||||
);
|
||||
}
|
||||
|
||||
export function isModelInThinkingMode(
|
||||
llm: BaseLanguageModel,
|
||||
): llm is BaseLanguageModel & { lc_kwargs: { invocationKwargs: { thinking: { type: string } } } } {
|
||||
return (
|
||||
'lc_kwargs' in llm &&
|
||||
'invocationKwargs' in llm.lc_kwargs &&
|
||||
typeof llm.lc_kwargs.invocationKwargs === 'object' &&
|
||||
'thinking' in llm.lc_kwargs.invocationKwargs &&
|
||||
llm.lc_kwargs.invocationKwargs.thinking.type === 'enabled'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if the LLM has a format property(Ollama)
|
||||
*/
|
||||
export function isModelWithFormat(
|
||||
llm: BaseLanguageModel,
|
||||
): llm is BaseLanguageModel & { format: string } {
|
||||
return 'format' in llm && typeof llm.format !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an LLM is configured to output JSON and returns the appropriate output parser
|
||||
*/
|
||||
export function getOutputParserForLLM(
|
||||
llm: BaseChatModel | BaseLanguageModel,
|
||||
): BaseLLMOutputParser<string | Record<string, unknown>> {
|
||||
if (isModelWithResponseFormat(llm) && llm.modelKwargs?.response_format?.type === 'json_object') {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
if (isModelWithFormat(llm) && llm.format === 'json') {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
if (isModelInThinkingMode(llm)) {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
// For example Mistral's Magistral models (LmChatMistralCloud node)
|
||||
if (llm.metadata?.output_format === 'json') {
|
||||
return new NaiveJsonOutputParser();
|
||||
}
|
||||
|
||||
return new StringOutputParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple chain for LLMs without output parsers
|
||||
*/
|
||||
async function executeSimpleChain({
|
||||
context,
|
||||
llm,
|
||||
query,
|
||||
prompt,
|
||||
}: {
|
||||
context: IExecuteFunctions;
|
||||
llm: BaseChatModel | BaseLanguageModel;
|
||||
query: string;
|
||||
prompt: ChatPromptTemplate | PromptTemplate;
|
||||
}) {
|
||||
const outputParser = getOutputParserForLLM(llm);
|
||||
|
||||
const chain = prompt.pipe(llm).pipe(outputParser).withConfig(getTracingConfig(context));
|
||||
|
||||
// Execute the chain
|
||||
const response = await chain.invoke({
|
||||
query,
|
||||
signal: context.getExecutionCancelSignal(),
|
||||
});
|
||||
|
||||
// Ensure response is always returned as an array
|
||||
return [response];
|
||||
}
|
||||
|
||||
// Some models nodes, like OpenAI, can define built-in tools in their metadata
|
||||
function withBuiltInTools(llm: BaseChatModel | BaseLanguageModel) {
|
||||
const modelTools = (llm.metadata?.tools as Tool[]) ?? [];
|
||||
if (modelTools.length && isChatInstance(llm) && llm.bindTools) {
|
||||
return llm.bindTools(modelTools);
|
||||
}
|
||||
return llm;
|
||||
}
|
||||
|
||||
function prepareLlm(
|
||||
llm: BaseLanguageModel | BaseChatModel,
|
||||
fallbackLlm?: BaseLanguageModel | BaseChatModel | null,
|
||||
) {
|
||||
const mainLlm = withBuiltInTools(llm);
|
||||
if (fallbackLlm) {
|
||||
return mainLlm.withFallbacks([withBuiltInTools(fallbackLlm)]);
|
||||
}
|
||||
return mainLlm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and executes an LLM chain with the given prompt and optional output parsers
|
||||
*/
|
||||
export async function executeChain({
|
||||
context,
|
||||
itemIndex,
|
||||
query,
|
||||
llm,
|
||||
outputParser,
|
||||
messages,
|
||||
fallbackLlm,
|
||||
}: ChainExecutionParams): Promise<unknown[]> {
|
||||
const version = context.getNode().typeVersion;
|
||||
const model = prepareLlm(llm, fallbackLlm) as BaseChatModel | BaseLanguageModel;
|
||||
// If no output parsers provided, use a simple chain with basic prompt template
|
||||
if (!outputParser) {
|
||||
const promptTemplate = await createPromptTemplate({
|
||||
context,
|
||||
itemIndex,
|
||||
llm,
|
||||
messages,
|
||||
query,
|
||||
});
|
||||
|
||||
return await executeSimpleChain({
|
||||
context,
|
||||
llm: model,
|
||||
query,
|
||||
prompt: promptTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
const formatInstructions = outputParser.getFormatInstructions();
|
||||
|
||||
// Create a prompt template with format instructions
|
||||
const promptWithInstructions = await createPromptTemplate({
|
||||
context,
|
||||
itemIndex,
|
||||
llm,
|
||||
messages,
|
||||
formatInstructions,
|
||||
query,
|
||||
});
|
||||
|
||||
let chain: Runnable<{ query: string }>;
|
||||
if (version >= 1.9) {
|
||||
// use getAgentStepsParser to have more robust output parsing
|
||||
chain = promptWithInstructions
|
||||
.pipe(model)
|
||||
.pipe(getAgentStepsParser(outputParser))
|
||||
.withConfig(getTracingConfig(context));
|
||||
} else {
|
||||
chain = promptWithInstructions
|
||||
.pipe(model)
|
||||
.pipe(outputParser)
|
||||
.withConfig(getTracingConfig(context));
|
||||
}
|
||||
|
||||
const response = await chain.invoke({ query }, { signal: context.getExecutionCancelSignal() });
|
||||
|
||||
// Ensure response is always returned as an array
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Array.isArray(response) ? response : [response];
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
AIMessagePromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import type { IDataObject, INodeInputConfiguration, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
promptTypeOptions,
|
||||
promptTypeOptionsDeprecated,
|
||||
textFromGuardrailsNode,
|
||||
textFromPreviousNode,
|
||||
} from '@utils/descriptions';
|
||||
import { getBatchingOptionFields, getTemplateNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
/**
|
||||
* Dynamic input configuration generation based on node parameters
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
export function getInputs(parameters: IDataObject) {
|
||||
const inputs: INodeInputConfiguration[] = [
|
||||
{ displayName: '', type: 'main' },
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: 'ai_languageModel',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const needsFallback = parameters?.needsFallback;
|
||||
|
||||
if (needsFallback === true) {
|
||||
inputs.push({
|
||||
displayName: 'Fallback Model',
|
||||
maxConnections: 1,
|
||||
type: 'ai_languageModel',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
|
||||
// If `hasOutputParser` is undefined it must be version 1.3 or earlier so we
|
||||
// always add the output parser input
|
||||
const hasOutputParser = parameters?.hasOutputParser;
|
||||
if (hasOutputParser === undefined || hasOutputParser === true) {
|
||||
inputs.push({
|
||||
displayName: 'Output Parser',
|
||||
type: 'ai_outputParser',
|
||||
maxConnections: 1,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node properties configuration
|
||||
*/
|
||||
export const nodeProperties: INodeProperties[] = [
|
||||
getTemplateNoticeField(1978),
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.input }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chat_input }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.1, 1.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chatInput }}',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1.3],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...promptTypeOptionsDeprecated,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { lte: 1.3 } }, { _cnd: { gte: 1.8 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...promptTypeOptions,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.8 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...textFromGuardrailsNode,
|
||||
displayOptions: { show: { promptType: ['guardrails'], '@version': [{ _cnd: { gte: 1.5 } }] } },
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.5 } }] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt (User Message)',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
builderHint: {
|
||||
message:
|
||||
'Use expressions to include dynamic data from previous nodes (e.g., "={{ $json.input }}"). Static text prompts ignore incoming data.',
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
promptType: ['define'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Require Specific Output Format',
|
||||
name: 'hasOutputParser',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [1, 1.1, 1.3],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Fallback Model',
|
||||
name: 'needsFallback',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [1, 1.1, 1.3],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Chat Messages (if Using a Chat Model)',
|
||||
name: 'messages',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
placeholder: 'Add prompt',
|
||||
options: [
|
||||
{
|
||||
name: 'messageValues',
|
||||
displayName: 'Prompt',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Type Name or ID',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'AI',
|
||||
value: AIMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
{
|
||||
name: 'System',
|
||||
value: SystemMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
{
|
||||
name: 'User',
|
||||
value: HumanMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
],
|
||||
default: SystemMessagePromptTemplate.lc_name(),
|
||||
},
|
||||
{
|
||||
displayName: 'Message Type',
|
||||
name: 'messageType',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: [HumanMessagePromptTemplate.lc_name()],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description: 'Simple text message',
|
||||
},
|
||||
{
|
||||
name: 'Image (Binary)',
|
||||
value: 'imageBinary',
|
||||
description: 'Process the binary input from the previous node',
|
||||
},
|
||||
{
|
||||
name: 'Image (URL)',
|
||||
value: 'imageUrl',
|
||||
description: 'Process the image from the specified URL',
|
||||
},
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
{
|
||||
displayName: 'Image Data Field Name',
|
||||
name: 'binaryImageDataKey',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
required: true,
|
||||
description:
|
||||
"The name of the field in the chain's input that contains the binary image file to be processed",
|
||||
displayOptions: {
|
||||
show: {
|
||||
messageType: ['imageBinary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Image URL',
|
||||
name: 'imageUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'URL to the image to be processed',
|
||||
displayOptions: {
|
||||
show: {
|
||||
messageType: ['imageUrl'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Image Details',
|
||||
description:
|
||||
'Control how the model processes the image and generates its textual understanding',
|
||||
name: 'imageDetail',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: [HumanMessagePromptTemplate.lc_name()],
|
||||
messageType: ['imageBinary', 'imageUrl'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
value: 'auto',
|
||||
description:
|
||||
'Model will use the auto setting which will look at the image input size and decide if it should use the low or high setting',
|
||||
},
|
||||
{
|
||||
name: 'Low',
|
||||
value: 'low',
|
||||
description:
|
||||
'The model will receive a low-res 512px x 512px version of the image, and represent the image with a budget of 65 tokens. This allows the API to return faster responses and consume fewer input tokens for use cases that do not require high detail.',
|
||||
},
|
||||
{
|
||||
name: 'High',
|
||||
value: 'high',
|
||||
description:
|
||||
'Allows the model to see the low res image and then creates detailed crops of input images as 512px squares based on the input image size. Each of the detailed crops uses twice the token budget (65 tokens) for a total of 129 tokens.',
|
||||
},
|
||||
],
|
||||
default: 'auto',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Message',
|
||||
name: 'message',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
messageType: ['imageBinary', 'imageUrl'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
getBatchingOptionFields({
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.7 } }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
hasOutputParser: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
|
||||
name: 'fallbackNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
needsFallback: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import type { IExecuteFunctions, IBinaryData } from 'n8n-workflow';
|
||||
import { NodeOperationError, NodeConnectionTypes, OperationalError } from 'n8n-workflow';
|
||||
|
||||
import type { MessageTemplate } from './types';
|
||||
|
||||
export class UnsupportedMimeTypeError extends OperationalError {}
|
||||
|
||||
/**
|
||||
* Converts binary image data to a data URI
|
||||
*/
|
||||
export function dataUriFromImageData(binaryData: IBinaryData, bufferData: Buffer): string {
|
||||
if (!binaryData.mimeType?.startsWith('image/')) {
|
||||
throw new UnsupportedMimeTypeError(
|
||||
`${binaryData.mimeType} is not a supported type of binary data. Only images are supported.`,
|
||||
);
|
||||
}
|
||||
return `data:${binaryData.mimeType};base64,${bufferData.toString('base64')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a human message with image content from either binary data or URL
|
||||
*/
|
||||
export async function createImageMessage({
|
||||
context,
|
||||
itemIndex,
|
||||
message,
|
||||
}: {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
message: MessageTemplate;
|
||||
}): Promise<HumanMessage> {
|
||||
// Validate message type
|
||||
if (message.messageType !== 'imageBinary' && message.messageType !== 'imageUrl') {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
'Invalid message type. Only imageBinary and imageUrl are supported',
|
||||
);
|
||||
}
|
||||
|
||||
const detail = message.imageDetail === 'auto' ? undefined : message.imageDetail;
|
||||
|
||||
// Handle image URL case
|
||||
if (message.messageType === 'imageUrl' && message.imageUrl) {
|
||||
return new HumanMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: message.imageUrl,
|
||||
detail,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Handle binary image case
|
||||
const binaryDataKey = message.binaryImageDataKey ?? 'data';
|
||||
const inputData = context.getInputData()[itemIndex];
|
||||
const binaryData = inputData.binary?.[binaryDataKey] as IBinaryData;
|
||||
|
||||
if (!binaryData) {
|
||||
throw new NodeOperationError(context.getNode(), 'No binary data set.');
|
||||
}
|
||||
|
||||
const bufferData = await context.helpers.getBinaryDataBuffer(itemIndex, binaryDataKey);
|
||||
const model = (await context.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
try {
|
||||
// Create data URI from binary data
|
||||
const dataURI = dataUriFromImageData(binaryData, bufferData);
|
||||
|
||||
// Some models need different image URL formats
|
||||
const directUriModels = [ChatGoogleGenerativeAI, ChatOllama];
|
||||
const imageUrl = directUriModels.some((i) => model instanceof i)
|
||||
? dataURI
|
||||
: { url: dataURI, detail };
|
||||
|
||||
return new HumanMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: imageUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedMimeTypeError)
|
||||
throw new NodeOperationError(context.getNode(), error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { executeChain } from './chainExecutor';
|
||||
export { getInputs, nodeProperties } from './config';
|
||||
export { formatResponse } from './responseFormatter';
|
||||
export type { MessageTemplate } from './types';
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { type IExecuteFunctions, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { getPromptInputByType } from '@utils/helpers';
|
||||
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import { executeChain } from './chainExecutor';
|
||||
import { type MessageTemplate } from './types';
|
||||
|
||||
async function getChatModel(
|
||||
ctx: IExecuteFunctions,
|
||||
index: number = 0,
|
||||
): Promise<BaseLanguageModel | undefined> {
|
||||
const connectedModels = await ctx.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0);
|
||||
|
||||
let model;
|
||||
|
||||
if (Array.isArray(connectedModels) && index !== undefined) {
|
||||
if (connectedModels.length <= index) {
|
||||
return undefined;
|
||||
}
|
||||
// We get the models in reversed order from the workflow so we need to reverse them again to match the right index
|
||||
const reversedModels = [...connectedModels].reverse();
|
||||
model = reversedModels[index] as BaseLanguageModel;
|
||||
} else {
|
||||
model = connectedModels as BaseLanguageModel;
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export const processItem = async (ctx: IExecuteFunctions, itemIndex: number) => {
|
||||
const needsFallback = ctx.getNodeParameter('needsFallback', 0, false) as boolean;
|
||||
const llm = await getChatModel(ctx, 0);
|
||||
assert(llm, 'Please connect a model to the Chat Model input');
|
||||
|
||||
const fallbackLlm = needsFallback ? await getChatModel(ctx, 1) : null;
|
||||
if (needsFallback && !fallbackLlm) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Please connect a model to the Fallback Model input or disable the fallback option',
|
||||
);
|
||||
}
|
||||
|
||||
// Get output parser if configured
|
||||
const outputParser = await getOptionalOutputParser(ctx, itemIndex);
|
||||
|
||||
// Get user prompt based on node version
|
||||
let prompt: string;
|
||||
|
||||
if (ctx.getNode().typeVersion <= 1.3) {
|
||||
prompt = ctx.getNodeParameter('prompt', itemIndex) as string;
|
||||
} else {
|
||||
prompt = getPromptInputByType({
|
||||
ctx,
|
||||
i: itemIndex,
|
||||
inputKey: 'text',
|
||||
promptTypeKey: 'promptType',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate prompt
|
||||
if (prompt === undefined) {
|
||||
throw new NodeOperationError(ctx.getNode(), "The 'prompt' parameter is empty.");
|
||||
}
|
||||
|
||||
// Get chat messages if configured
|
||||
const messages = ctx.getNodeParameter(
|
||||
'messages.messageValues',
|
||||
itemIndex,
|
||||
[],
|
||||
) as MessageTemplate[];
|
||||
|
||||
// Execute the chain
|
||||
return await executeChain({
|
||||
context: ctx,
|
||||
itemIndex,
|
||||
query: prompt,
|
||||
llm,
|
||||
outputParser,
|
||||
messages,
|
||||
fallbackLlm,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { AgentAction, AgentFinish } from '@langchain/core/agents';
|
||||
import { BaseMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
|
||||
import {
|
||||
AIMessagePromptTemplate,
|
||||
ChatPromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
PromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import { isChatInstance } from '@n8n/ai-utilities';
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import { createImageMessage } from './imageUtils';
|
||||
import type { MessageTemplate, PromptParams } from './types';
|
||||
|
||||
/**
|
||||
* Creates a basic query template that may include format instructions
|
||||
*/
|
||||
function buildQueryTemplate(formatInstructions?: string): PromptTemplate {
|
||||
return new PromptTemplate({
|
||||
template: `{query}${formatInstructions ? '\n{formatInstructions}' : ''}`,
|
||||
inputVariables: ['query'],
|
||||
partialVariables: formatInstructions ? { formatInstructions } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of message templates into LangChain message objects
|
||||
*/
|
||||
async function processMessageTemplates({
|
||||
context,
|
||||
itemIndex,
|
||||
messages,
|
||||
}: {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
messages: MessageTemplate[];
|
||||
}): Promise<BaseMessagePromptTemplateLike[]> {
|
||||
return await Promise.all(
|
||||
messages.map(async (message) => {
|
||||
// Find the appropriate message class based on type
|
||||
const messageClass = [
|
||||
SystemMessagePromptTemplate,
|
||||
AIMessagePromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
].find((m) => m.lc_name() === message.type);
|
||||
|
||||
if (!messageClass) {
|
||||
throw new OperationalError('Invalid message type', {
|
||||
extra: { messageType: message.type },
|
||||
});
|
||||
}
|
||||
|
||||
// Handle image messages specially for human messages
|
||||
if (messageClass === HumanMessagePromptTemplate && message.messageType !== 'text') {
|
||||
return await createImageMessage({ context, itemIndex, message });
|
||||
}
|
||||
|
||||
// Process text messages
|
||||
// Escape curly braces in the message to prevent LangChain from treating them as variables
|
||||
return messageClass.fromTemplate(
|
||||
(message.message || '').replace(/[{}]/g, (match) => match + match),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes the prompt template by adding or updating the query in the message chain
|
||||
*/
|
||||
async function finalizePromptTemplate({
|
||||
parsedMessages,
|
||||
queryTemplate,
|
||||
query,
|
||||
}: {
|
||||
parsedMessages: BaseMessagePromptTemplateLike[];
|
||||
queryTemplate: PromptTemplate;
|
||||
query?: string;
|
||||
}): Promise<ChatPromptTemplate> {
|
||||
// Check if the last message is a human message with multi-content array
|
||||
const lastMessage = parsedMessages[parsedMessages.length - 1];
|
||||
|
||||
if (lastMessage instanceof HumanMessage && Array.isArray(lastMessage.content)) {
|
||||
// Add the query to the existing human message content
|
||||
const humanMessage = new HumanMessagePromptTemplate(queryTemplate);
|
||||
|
||||
// Format the message with the query and add the content synchronously
|
||||
const formattedMessage = await humanMessage.format({ query });
|
||||
|
||||
// Create a new array with the existing content plus the new item
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
// Clone the current content array and add the new item
|
||||
const updatedContent = [
|
||||
...lastMessage.content,
|
||||
{
|
||||
text: formattedMessage.content.toString(),
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
// Replace the content with the updated array
|
||||
lastMessage.content = updatedContent;
|
||||
}
|
||||
} else {
|
||||
// Otherwise, add a new human message with the query
|
||||
parsedMessages.push(new HumanMessagePromptTemplate(queryTemplate));
|
||||
}
|
||||
|
||||
return ChatPromptTemplate.fromMessages(parsedMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the appropriate prompt template based on model type (chat vs completion)
|
||||
* and provided messages
|
||||
*/
|
||||
export async function createPromptTemplate({
|
||||
context,
|
||||
itemIndex,
|
||||
llm,
|
||||
messages,
|
||||
formatInstructions,
|
||||
query,
|
||||
}: PromptParams) {
|
||||
// Create base query template
|
||||
const queryTemplate = buildQueryTemplate(formatInstructions);
|
||||
|
||||
// For non-chat models, just return the query template
|
||||
if (!isChatInstance(llm)) {
|
||||
return queryTemplate;
|
||||
}
|
||||
|
||||
// For chat models, process the messages if provided
|
||||
const parsedMessages = messages?.length
|
||||
? await processMessageTemplates({ context, itemIndex, messages })
|
||||
: [];
|
||||
|
||||
// Add or update the query in the message chain
|
||||
return await finalizePromptTemplate({
|
||||
parsedMessages,
|
||||
queryTemplate,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
const isMessage = (message: unknown): message is BaseMessage => {
|
||||
return message instanceof BaseMessage;
|
||||
};
|
||||
|
||||
const isAgentFinish = (value: unknown): value is AgentFinish => {
|
||||
return typeof value === 'object' && value !== null && 'returnValues' in value;
|
||||
};
|
||||
|
||||
export const getAgentStepsParser =
|
||||
(outputParser: N8nOutputParser) =>
|
||||
async (
|
||||
steps: AgentFinish | BaseMessage | AgentAction[] | string,
|
||||
): Promise<string | Record<string, unknown>> => {
|
||||
if (typeof steps === 'string') {
|
||||
return (await outputParser.parse(steps)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Check if the steps contain the 'format_final_json_response' tool invocation.
|
||||
if (Array.isArray(steps)) {
|
||||
const responseParserTool = steps.find((step) => step.tool === 'format_final_json_response');
|
||||
if (responseParserTool) {
|
||||
const toolInput = responseParserTool.toolInput;
|
||||
// Ensure the tool input is a string
|
||||
const parserInput = toolInput instanceof Object ? JSON.stringify(toolInput) : toolInput;
|
||||
const parsedOutput = (await outputParser.parse(parserInput)) as Record<string, unknown>;
|
||||
return parsedOutput;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof steps === 'object' && isMessage(steps)) {
|
||||
const output = steps.text;
|
||||
|
||||
const parsedOutput = (await outputParser.parse(output)) as Record<string, unknown>;
|
||||
return parsedOutput;
|
||||
}
|
||||
|
||||
if (isAgentFinish(steps)) {
|
||||
const returnValues = steps.returnValues;
|
||||
const parsedOutput = (await outputParser.parse(JSON.stringify(returnValues))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return parsedOutput;
|
||||
}
|
||||
|
||||
throw new Error('Failed to parse agent steps');
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Formats the response from the LLM chain into a consistent structure
|
||||
*/
|
||||
export function formatResponse(response: unknown, returnUnwrappedObject: boolean): IDataObject {
|
||||
if (typeof response === 'string') {
|
||||
return {
|
||||
text: response.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return {
|
||||
data: response,
|
||||
};
|
||||
}
|
||||
|
||||
if (response instanceof Object) {
|
||||
if (returnUnwrappedObject) {
|
||||
return response as IDataObject;
|
||||
}
|
||||
|
||||
// If the response is an object and we are not unwrapping it, we need to stringify it
|
||||
// to be backwards compatible with older versions of the chain(< 1.6)
|
||||
return {
|
||||
text: JSON.stringify(response),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: response,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
/**
|
||||
* Interface for describing a message template in the UI
|
||||
*/
|
||||
export interface MessageTemplate {
|
||||
type: string;
|
||||
message: string;
|
||||
messageType: 'text' | 'imageBinary' | 'imageUrl';
|
||||
binaryImageDataKey?: string;
|
||||
imageUrl?: string;
|
||||
imageDetail?: 'auto' | 'low' | 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for prompt creation
|
||||
*/
|
||||
export interface PromptParams {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
llm: BaseLanguageModel | BaseChatModel;
|
||||
messages?: MessageTemplate[];
|
||||
formatInstructions?: string;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for chain execution
|
||||
*/
|
||||
export interface ChainExecutionParams {
|
||||
context: IExecuteFunctions;
|
||||
itemIndex: number;
|
||||
query: string;
|
||||
llm: BaseLanguageModel;
|
||||
outputParser?: N8nOutputParser;
|
||||
messages?: MessageTemplate[];
|
||||
fallbackLlm?: BaseLanguageModel | null;
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as helperModule from '@utils/helpers';
|
||||
import * as outputParserModule from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import { ChainLlm } from '../ChainLlm.node';
|
||||
import * as executeChainModule from '../methods/chainExecutor';
|
||||
import * as responseFormatterModule from '../methods/responseFormatter';
|
||||
|
||||
jest.mock('@utils/helpers', () => ({
|
||||
getPromptInputByType: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/output_parsers/N8nOutputParser', () => ({
|
||||
getOptionalOutputParser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/chainExecutor', () => ({
|
||||
executeChain: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/responseFormatter', () => ({
|
||||
formatResponse: jest.fn().mockImplementation((response) => {
|
||||
if (typeof response === 'string') {
|
||||
return { text: response.trim() };
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ChainLlm Node', () => {
|
||||
let node: ChainLlm;
|
||||
let mockExecuteFunction: jest.Mocked<IExecuteFunctions>;
|
||||
let needsFallback: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new ChainLlm();
|
||||
mockExecuteFunction = mock<IExecuteFunctions>();
|
||||
|
||||
mockExecuteFunction.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
needsFallback = false;
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([{ json: {} }]);
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'messages.messageValues') return [];
|
||||
if (param === 'needsFallback') return needsFallback;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue(fakeLLM);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('description', () => {
|
||||
it('should have the expected properties', () => {
|
||||
expect(node.description).toBeDefined();
|
||||
expect(node.description.name).toBe('chainLlm');
|
||||
expect(node.description.displayName).toBe('Basic LLM Chain');
|
||||
expect(node.description.version).toContain(1.5);
|
||||
expect(node.description.properties).toBeDefined();
|
||||
expect(node.description.inputs).toBeDefined();
|
||||
expect(node.description.outputs).toEqual([NodeConnectionTypes.Main]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute the chain with the correct parameters', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt',
|
||||
fallbackLlm: null,
|
||||
llm: expect.any(FakeChatModel),
|
||||
outputParser: undefined,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(mockExecuteFunction.logger.debug).toHaveBeenCalledWith('Executing Basic LLM Chain');
|
||||
|
||||
expect(result).toEqual([[{ json: expect.any(Object) }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(2);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should use the prompt parameter directly for older versions', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.3,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'prompt') return 'Old version prompt';
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Old version prompt',
|
||||
fallbackLlm: null,
|
||||
llm: expect.any(Object),
|
||||
outputParser: undefined,
|
||||
messages: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if prompt is empty', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue(undefined);
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
mockExecuteFunction.getNode.mockReturnValue({ name: 'Test Node' } as INode);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow(/prompt.*empty/);
|
||||
});
|
||||
|
||||
it('should continue on failure when configured', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
const error = new Error('Test error');
|
||||
(executeChainModule.executeChain as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: { error: 'Test error' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple response items from executeChain', async () => {
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([
|
||||
'Response 1',
|
||||
'Response 2',
|
||||
]);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('batching (version 1.7+)', () => {
|
||||
beforeEach(() => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.7,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
});
|
||||
|
||||
it('should process items in batches with default settings', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(3);
|
||||
expect(result[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should process items in smaller batches', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3')
|
||||
.mockReturnValueOnce('Test prompt 4');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3'])
|
||||
.mockResolvedValueOnce(['Response 4']);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledTimes(4);
|
||||
expect(result[0]).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should handle errors in batches with continueOnFail', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockRejectedValueOnce(new Error('Test error'));
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][1].json).toEqual({ error: 'Test error' });
|
||||
});
|
||||
|
||||
it('should handle OpenAI rate limit errors in batches', async () => {
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation(
|
||||
(param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
},
|
||||
);
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
const openAiError = new NodeApiError(mockExecuteFunction.getNode(), {
|
||||
message: 'Rate limit exceeded',
|
||||
cause: { error: { code: 'rate_limit_exceeded' } },
|
||||
});
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockRejectedValueOnce(openAiError);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][1].json).toEqual({ error: expect.stringContaining('Rate limit') });
|
||||
});
|
||||
});
|
||||
|
||||
it('should unwrap object responses when node version is 1.6 or higher', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const structuredResponse = {
|
||||
person: { name: 'John', age: 30 },
|
||||
items: ['item1', 'item2'],
|
||||
active: true,
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, true);
|
||||
});
|
||||
|
||||
it('should unwrap object responses when output parser is provided regardless of version', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
);
|
||||
|
||||
const structuredResponse = {
|
||||
result: 'success',
|
||||
data: { key: 'value' },
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, true);
|
||||
});
|
||||
|
||||
it('should wrap object responses as text when node version is lower than 1.6 and no output parser', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.5,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const structuredResponse = {
|
||||
person: { name: 'John', age: 30 },
|
||||
items: ['item1', 'item2'],
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([structuredResponse]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(formatResponseSpy).toHaveBeenCalledWith(structuredResponse, false);
|
||||
});
|
||||
|
||||
it('should handle a mix of different response types with the correct wrapping', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const mixedResponses = ['Text response', { structured: 'object' }, ['array', 'response']];
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(mixedResponses);
|
||||
|
||||
(responseFormatterModule.formatResponse as jest.Mock).mockClear();
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenCalledTimes(3);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Text response',
|
||||
true,
|
||||
);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ structured: 'object' },
|
||||
true,
|
||||
);
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['array', 'response'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle LLM responses containing JSON with markdown content', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue(
|
||||
'Generate markdown documentation',
|
||||
);
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const markdownResponse = {
|
||||
title: 'API Documentation',
|
||||
sections: [
|
||||
{
|
||||
name: 'Authentication',
|
||||
content:
|
||||
"# Authentication\n\nUse API keys for all requests:\n\n```javascript\nconst headers = {\n 'Authorization': 'Bearer YOUR_API_KEY'\n};\n```",
|
||||
},
|
||||
{
|
||||
name: 'Endpoints',
|
||||
content:
|
||||
'## Available Endpoints\n\n* GET /users - List all users\n* POST /users - Create a user\n* GET /users/{id} - Get user details',
|
||||
},
|
||||
],
|
||||
examples: {
|
||||
curl: "```bash\ncurl -X GET https://api.example.com/users \\\n -H 'Authorization: Bearer YOUR_API_KEY'\n```",
|
||||
response: '```json\n{\n "users": [],\n "count": 0\n}\n```',
|
||||
},
|
||||
};
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue([markdownResponse]);
|
||||
|
||||
(responseFormatterModule.formatResponse as jest.Mock).mockImplementation(
|
||||
(response, shouldUnwrap) => {
|
||||
if (shouldUnwrap && typeof response === 'object') {
|
||||
return response;
|
||||
}
|
||||
return { text: JSON.stringify(response) };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: markdownResponse,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(responseFormatterModule.formatResponse).toHaveBeenCalledWith(markdownResponse, true);
|
||||
});
|
||||
|
||||
it('should use fallback llm if enabled', async () => {
|
||||
needsFallback = true;
|
||||
(helperModule.getPromptInputByType as jest.Mock).mockReturnValue('Test prompt');
|
||||
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock).mockResolvedValue(['Test response']);
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const fakeFallbackLLM = new FakeChatModel({});
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([fakeLLM, fakeFallbackLLM]);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(executeChainModule.executeChain).toHaveBeenCalledWith({
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt',
|
||||
fallbackLlm: expect.any(FakeChatModel),
|
||||
llm: expect.any(FakeChatModel),
|
||||
outputParser: undefined,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(mockExecuteFunction.logger.debug).toHaveBeenCalledWith('Executing Basic LLM Chain');
|
||||
|
||||
expect(result).toEqual([[{ json: expect.any(Object) }]]);
|
||||
});
|
||||
|
||||
it('should pass correct itemIndex to getOptionalOutputParser', async () => {
|
||||
// Clear any previous calls to the mock
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockClear();
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3');
|
||||
|
||||
const mockParser1 = mock<outputParserModule.N8nOutputParser>();
|
||||
const mockParser2 = mock<outputParserModule.N8nOutputParser>();
|
||||
const mockParser3 = mock<outputParserModule.N8nOutputParser>();
|
||||
|
||||
// Use the already mocked function instead of creating a spy
|
||||
// First call is for the initial check in execute(), then one per item
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock)
|
||||
.mockResolvedValueOnce(undefined) // Initial call in execute()
|
||||
.mockResolvedValueOnce(mockParser1)
|
||||
.mockResolvedValueOnce(mockParser2)
|
||||
.mockResolvedValueOnce(mockParser3);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3']);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// Verify getOptionalOutputParser was called with correct indices
|
||||
// First call without index, then 3 calls with indices
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(4);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockExecuteFunction,
|
||||
); // Initial call
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockExecuteFunction,
|
||||
0,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockExecuteFunction,
|
||||
1,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
mockExecuteFunction,
|
||||
2,
|
||||
);
|
||||
|
||||
// Verify executeChain was called with the corresponding parsers
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(1, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 0,
|
||||
query: 'Test prompt 1',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser1,
|
||||
messages: [],
|
||||
});
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(2, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 1,
|
||||
query: 'Test prompt 2',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser2,
|
||||
messages: [],
|
||||
});
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(3, {
|
||||
context: mockExecuteFunction,
|
||||
itemIndex: 2,
|
||||
query: 'Test prompt 3',
|
||||
llm: expect.any(Object),
|
||||
fallbackLlm: null,
|
||||
outputParser: mockParser3,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different output parsers for each item', async () => {
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.6,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2');
|
||||
|
||||
// First item has no parser, second has a parser
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(mock<outputParserModule.N8nOutputParser>());
|
||||
|
||||
const response1 = { text: 'plain response' };
|
||||
const response2 = { structured: 'response' };
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce([response1])
|
||||
.mockResolvedValueOnce([response2]);
|
||||
|
||||
const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse');
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// First item without parser should not unwrap objects (even in v1.6)
|
||||
// Actually, let me check the logic again... v1.6 unwraps by default
|
||||
// but having an output parser always triggers unwrapping
|
||||
expect(formatResponseSpy).toHaveBeenNthCalledWith(1, response1, true); // v1.6 unwraps
|
||||
expect(formatResponseSpy).toHaveBeenNthCalledWith(2, response2, true); // has parser, unwraps
|
||||
});
|
||||
|
||||
it('should maintain parser consistency across batch processing', async () => {
|
||||
// Clear any previous calls to the mock
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockClear();
|
||||
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Chain LLM',
|
||||
typeVersion: 1.7,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'batching.batchSize') return 2;
|
||||
if (param === 'batching.delayBetweenBatches') return 0;
|
||||
if (param === 'messages.messageValues') return [];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
(helperModule.getPromptInputByType as jest.Mock)
|
||||
.mockReturnValueOnce('Test prompt 1')
|
||||
.mockReturnValueOnce('Test prompt 2')
|
||||
.mockReturnValueOnce('Test prompt 3')
|
||||
.mockReturnValueOnce('Test prompt 4');
|
||||
|
||||
const mockParsers = [
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
undefined,
|
||||
mock<outputParserModule.N8nOutputParser>(),
|
||||
undefined,
|
||||
];
|
||||
|
||||
// Use the already mocked function instead of creating a spy
|
||||
// Account for initial call without index
|
||||
(outputParserModule.getOptionalOutputParser as jest.Mock).mockImplementation(
|
||||
async (_ctx, index) => {
|
||||
if (index === undefined) return undefined; // Initial call
|
||||
return mockParsers[index];
|
||||
},
|
||||
);
|
||||
|
||||
(executeChainModule.executeChain as jest.Mock)
|
||||
.mockResolvedValueOnce(['Response 1'])
|
||||
.mockResolvedValueOnce(['Response 2'])
|
||||
.mockResolvedValueOnce(['Response 3'])
|
||||
.mockResolvedValueOnce(['Response 4']);
|
||||
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// Verify each item was processed with correct index
|
||||
// First call without index, then 4 calls with indices
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(5);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockExecuteFunction,
|
||||
); // Initial call
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockExecuteFunction,
|
||||
0,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
mockExecuteFunction,
|
||||
1,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
mockExecuteFunction,
|
||||
2,
|
||||
);
|
||||
expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
mockExecuteFunction,
|
||||
3,
|
||||
);
|
||||
|
||||
// Verify executeChain received correct parsers
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[0],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[1],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[2],
|
||||
}),
|
||||
);
|
||||
expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
outputParser: mockParsers[3],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { JsonOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import { FakeLLM, FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
import * as tracing from '@utils/tracing';
|
||||
|
||||
import { executeChain, NaiveJsonOutputParser } from '../methods/chainExecutor';
|
||||
import * as chainExecutor from '../methods/chainExecutor';
|
||||
import * as promptUtils from '../methods/promptUtils';
|
||||
|
||||
jest.mock('@utils/tracing', () => ({
|
||||
getTracingConfig: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('../methods/promptUtils', () => ({
|
||||
createPromptTemplate: jest.fn(),
|
||||
getAgentStepsParser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('chainExecutor', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getExecutionCancelSignal = jest.fn().mockReturnValue(undefined);
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.5,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOutputParserForLLM', () => {
|
||||
it('should return NaiveJsonOutputParser for OpenAI-like models with json_object response format', () => {
|
||||
const openAILikeModel = {
|
||||
modelKwargs: {
|
||||
response_format: {
|
||||
type: 'json_object',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(
|
||||
openAILikeModel as unknown as BaseChatModel,
|
||||
);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for Ollama models with json format', () => {
|
||||
const ollamaLikeModel = {
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(
|
||||
ollamaLikeModel as unknown as BaseChatModel,
|
||||
);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return StringOutputParser for models without JSON format settings', () => {
|
||||
const regularModel = new FakeLLM({});
|
||||
|
||||
const parser = chainExecutor.getOutputParserForLLM(regularModel);
|
||||
expect(parser).toBeInstanceOf(StringOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for Anthropic models in thinking mode', () => {
|
||||
const model = {
|
||||
lc_kwargs: {
|
||||
invocationKwargs: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const parser = chainExecutor.getOutputParserForLLM(model as unknown as BaseChatModel);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return NaiveJsonOutputParser for models with metadata output_format set to json', () => {
|
||||
const model = mock<BaseChatModel>({
|
||||
metadata: {
|
||||
output_format: 'json',
|
||||
},
|
||||
});
|
||||
const parser = chainExecutor.getOutputParserForLLM(model);
|
||||
expect(parser).toBeInstanceOf(NaiveJsonOutputParser);
|
||||
});
|
||||
|
||||
it('should return StringOutputParser for models with metadata output_format not set to json', () => {
|
||||
const model = mock<BaseChatModel>({
|
||||
metadata: {
|
||||
output_format: 'text',
|
||||
},
|
||||
});
|
||||
const parser = chainExecutor.getOutputParserForLLM(model);
|
||||
expect(parser).toBeInstanceOf(StringOutputParser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NaiveJsonOutputParser', () => {
|
||||
it('should parse valid JSON directly', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonStr = '{"name": "John", "age": 30}';
|
||||
|
||||
const result = await parser.parse(jsonStr);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested JSON objects', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonStr = '{"person": {"name": "John", "age": 30}, "active": true}';
|
||||
|
||||
const result = await parser.parse(jsonStr);
|
||||
|
||||
expect(result).toEqual({
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
},
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use parent class parser for malformed JSON', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const superParseSpy = jest.spyOn(JsonOutputParser.prototype, 'parse').mockResolvedValue({
|
||||
parsed: 'content',
|
||||
});
|
||||
|
||||
const malformedJson = 'Sure, here is your JSON: {"name": "John", "age": 30}';
|
||||
|
||||
await parser.parse(malformedJson);
|
||||
|
||||
expect(superParseSpy).toHaveBeenCalledWith(malformedJson);
|
||||
superParseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle JSON with surrounding text by using parent parser', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithText = 'Here is the result: {"result": "success", "code": 200}';
|
||||
|
||||
// Mock the parent class parse method
|
||||
const mockParsedResult = { result: 'success', code: 200 };
|
||||
const superParseSpy = jest
|
||||
.spyOn(JsonOutputParser.prototype, 'parse')
|
||||
.mockResolvedValue(mockParsedResult);
|
||||
|
||||
const result = await parser.parse(jsonWithText);
|
||||
|
||||
expect(superParseSpy).toHaveBeenCalledWith(jsonWithText);
|
||||
expect(result).toEqual(mockParsedResult);
|
||||
|
||||
superParseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly parse JSON with markdown text inside properties', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithMarkdown = `{
|
||||
"title": "Markdown Guide",
|
||||
"content": "# Heading 1\\n## Heading 2\\n* Bullet point\\n* Another bullet\\n\\n\`\`\`code block\`\`\`\\n> Blockquote",
|
||||
"description": "A guide with **bold** and *italic* text"
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithMarkdown);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Markdown Guide',
|
||||
content:
|
||||
'# Heading 1\n## Heading 2\n* Bullet point\n* Another bullet\n\n```code block```\n> Blockquote',
|
||||
description: 'A guide with **bold** and *italic* text',
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly parse JSON with markdown code blocks containing JSON', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithMarkdownAndNestedJson = `{
|
||||
"title": "JSON Examples",
|
||||
"examples": "Here's an example of JSON: \`\`\`json\\n{\\"nested\\": \\"json\\", \\"in\\": \\"code block\\"}\\n\`\`\`",
|
||||
"valid": true
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithMarkdownAndNestedJson);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'JSON Examples',
|
||||
examples:
|
||||
'Here\'s an example of JSON: ```json\n{"nested": "json", "in": "code block"}\n```',
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON with special characters in markdown content', async () => {
|
||||
const parser = new NaiveJsonOutputParser();
|
||||
const jsonWithSpecialChars = `{
|
||||
"title": "Special Characters",
|
||||
"content": "# Testing \\n\\n * List with **bold** & *italic*\\n * Item with [link](https://example.com)\\n * Math: 2 < 3 > 1 && true || false",
|
||||
"technical": "function test() { return x < y && z > w; }"
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(jsonWithSpecialChars);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Special Characters',
|
||||
content:
|
||||
'# Testing \n\n * List with **bold** & *italic*\n * Item with [link](https://example.com)\n * Math: 2 < 3 > 1 && true || false',
|
||||
technical: 'function test() { return x < y && z > w; }',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeChain', () => {
|
||||
it('should execute a simple chain without output parsers', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
});
|
||||
|
||||
expect(promptUtils.createPromptTemplate).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
messages: undefined,
|
||||
query: 'Hello',
|
||||
});
|
||||
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeStringOutputParserMock).toHaveBeenCalledWith(expect.any(StringOutputParser));
|
||||
expect(withConfigMock).toHaveBeenCalledWith(expect.any(Object));
|
||||
|
||||
expect(result).toEqual(['Test response']);
|
||||
|
||||
expect(tracing.getTracingConfig).toHaveBeenCalledWith(mockContext);
|
||||
});
|
||||
|
||||
it('should execute a chain with a single output parser', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'Test response' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mock<N8nOutputParser>(),
|
||||
});
|
||||
|
||||
expect(promptUtils.createPromptTemplate).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
messages: undefined,
|
||||
query: 'Hello',
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ result: 'Test response' }]);
|
||||
});
|
||||
|
||||
it('should wrap non-array responses in an array', async () => {
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockOutputParser = mock<N8nOutputParser>();
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'Test response' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toEqual([{ result: 'Test response' }]);
|
||||
});
|
||||
|
||||
it('should pass the execution cancel signal to the chain', async () => {
|
||||
// For this test, we'll just verify that getExecutionCancelSignal is called
|
||||
const fakeLLM = new FakeLLM({ response: 'Test response' });
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
});
|
||||
|
||||
expect(mockContext.getExecutionCancelSignal).toHaveBeenCalled();
|
||||
expect(mockChain.invoke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support chat models', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const mockChatPromptTemplate = ChatPromptTemplate.fromMessages([]);
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test chat response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockChatPromptTemplate.pipe = pipeMock;
|
||||
fakeChatModel.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockChatPromptTemplate);
|
||||
|
||||
const result = await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeChatModel,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['Test chat response']);
|
||||
});
|
||||
|
||||
it('should use JsonOutputParser for OpenAI models with json_object response format', async () => {
|
||||
const fakeOpenAIModel = new FakeChatModel({});
|
||||
(
|
||||
fakeOpenAIModel as unknown as { modelKwargs: { response_format: { type: string } } }
|
||||
).modelKwargs = {
|
||||
response_format: { type: 'json_object' },
|
||||
};
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('{"result": "json data"}'),
|
||||
};
|
||||
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeOpenAIModel,
|
||||
});
|
||||
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(expect.any(JsonOutputParser));
|
||||
});
|
||||
|
||||
it('should use JsonOutputParser for Ollama models with json format', async () => {
|
||||
const fakeOllamaModel = new FakeChatModel({});
|
||||
(fakeOllamaModel as unknown as { format: string }).format = 'json';
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('{"result": "json data"}'),
|
||||
};
|
||||
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeOllamaModel,
|
||||
});
|
||||
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(expect.any(JsonOutputParser));
|
||||
});
|
||||
|
||||
it('should use getAgentStepsParser for version 1.9+ when output parser is provided', async () => {
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.9,
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const mockOutputParser = mock<N8nOutputParser>({
|
||||
getFormatInstructions: jest.fn().mockReturnValue('Format as JSON'),
|
||||
});
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockAgentStepsParser = jest.fn().mockResolvedValue({ result: 'parsed' });
|
||||
(promptUtils.getAgentStepsParser as jest.Mock).mockReturnValue(mockAgentStepsParser);
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'parsed' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeAgentStepsParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeAgentStepsParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(promptUtils.getAgentStepsParser).toHaveBeenCalledWith(mockOutputParser);
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeAgentStepsParserMock).toHaveBeenCalledWith(mockAgentStepsParser);
|
||||
});
|
||||
|
||||
it('should use direct output parser for versions < 1.9 when output parser is provided', async () => {
|
||||
mockContext.getNode = jest.fn().mockReturnValue({
|
||||
typeVersion: 1.8,
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const mockOutputParser = mock<N8nOutputParser>({
|
||||
getFormatInstructions: jest.fn().mockReturnValue('Format as JSON'),
|
||||
});
|
||||
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}\n{formatInstructions}',
|
||||
inputVariables: ['query'],
|
||||
partialVariables: { formatInstructions: 'Format as JSON' },
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ result: 'parsed' }),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
outputParser: mockOutputParser,
|
||||
});
|
||||
|
||||
expect(promptUtils.getAgentStepsParser).not.toHaveBeenCalled();
|
||||
expect(pipeMock).toHaveBeenCalledWith(fakeLLM);
|
||||
expect(pipeOutputParserMock).toHaveBeenCalledWith(mockOutputParser);
|
||||
});
|
||||
|
||||
it('should handle fallback LLM with built-in tools', async () => {
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
const fakeFallbackLLM = new FakeChatModel({});
|
||||
const mockPromptTemplate = new PromptTemplate({
|
||||
template: '{query}',
|
||||
inputVariables: ['query'],
|
||||
});
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue('Test response'),
|
||||
};
|
||||
const withConfigMock = jest.fn().mockReturnValue(mockChain);
|
||||
const pipeStringOutputParserMock = jest.fn().mockReturnValue({
|
||||
withConfig: withConfigMock,
|
||||
});
|
||||
const pipeMock = jest.fn().mockReturnValue({
|
||||
pipe: pipeStringOutputParserMock,
|
||||
});
|
||||
|
||||
mockPromptTemplate.pipe = pipeMock;
|
||||
fakeLLM.pipe = jest.fn();
|
||||
fakeLLM.withFallbacks = jest.fn().mockReturnValue(fakeLLM);
|
||||
fakeFallbackLLM.pipe = jest.fn();
|
||||
|
||||
(promptUtils.createPromptTemplate as jest.Mock).mockResolvedValue(mockPromptTemplate);
|
||||
|
||||
await executeChain({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
query: 'Hello',
|
||||
llm: fakeLLM,
|
||||
fallbackLlm: fakeFallbackLLM,
|
||||
});
|
||||
|
||||
expect(fakeLLM.withFallbacks).toHaveBeenCalledWith([fakeFallbackLLM]);
|
||||
expect(pipeMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { getInputs, nodeProperties } from '../methods/config';
|
||||
|
||||
describe('config', () => {
|
||||
describe('getInputs', () => {
|
||||
it('should return basic inputs for all parameters', () => {
|
||||
const inputs = getInputs({});
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should exclude the OutputParser when hasOutputParser is false', () => {
|
||||
const inputs = getInputs({ hasOutputParser: false });
|
||||
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
});
|
||||
|
||||
it('should include the OutputParser when hasOutputParser is true', () => {
|
||||
const inputs = getInputs({ hasOutputParser: true });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should exclude the FallbackInput when needsFallback is false', () => {
|
||||
const inputs = getInputs({ hasOutputParser: true, needsFallback: false });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[0].type).toBe(NodeConnectionTypes.Main);
|
||||
expect(inputs[1].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiOutputParser);
|
||||
});
|
||||
|
||||
it('should include the FallbackInput when needsFallback is true', () => {
|
||||
const inputs = getInputs({ hasOutputParser: false, needsFallback: true });
|
||||
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(inputs[2].type).toBe(NodeConnectionTypes.AiLanguageModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeProperties', () => {
|
||||
it('should have the expected properties', () => {
|
||||
expect(Array.isArray(nodeProperties)).toBe(true);
|
||||
expect(nodeProperties.length).toBeGreaterThan(0);
|
||||
|
||||
const promptParams = nodeProperties.filter((prop) => prop.name === 'prompt');
|
||||
expect(promptParams.length).toBeGreaterThan(0);
|
||||
|
||||
const messagesParam = nodeProperties.find((prop) => prop.name === 'messages');
|
||||
expect(messagesParam).toBeDefined();
|
||||
expect(messagesParam?.type).toBe('fixedCollection');
|
||||
|
||||
const hasOutputParserParam = nodeProperties.find((prop) => prop.name === 'hasOutputParser');
|
||||
expect(hasOutputParserParam).toBeDefined();
|
||||
expect(hasOutputParserParam?.type).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, IBinaryData, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createImageMessage,
|
||||
dataUriFromImageData,
|
||||
UnsupportedMimeTypeError,
|
||||
} from '../methods/imageUtils';
|
||||
import type { MessageTemplate } from '../methods/types';
|
||||
|
||||
// Mock ChatGoogleGenerativeAI and ChatOllama
|
||||
jest.mock('@langchain/google-genai', () => ({
|
||||
ChatGoogleGenerativeAI: class MockChatGoogleGenerativeAI {},
|
||||
}));
|
||||
|
||||
jest.mock('@langchain/ollama', () => ({
|
||||
ChatOllama: class MockChatOllama {},
|
||||
}));
|
||||
|
||||
// Create a better mock for IExecuteFunctions that includes helpers
|
||||
const createMockExecuteFunctions = () => {
|
||||
const mockExec = mock<IExecuteFunctions>();
|
||||
// Add missing helpers property with mocked getBinaryDataBuffer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
mockExec.helpers = {
|
||||
getBinaryDataBuffer: jest.fn().mockResolvedValue(Buffer.from('Test image data')),
|
||||
} as any;
|
||||
return mockExec;
|
||||
};
|
||||
|
||||
describe('imageUtils', () => {
|
||||
describe('dataUriFromImageData', () => {
|
||||
it('should convert image data to data URI', () => {
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockBinaryData = mock<IBinaryData>({ mimeType: 'image/jpeg' });
|
||||
|
||||
const dataUri = dataUriFromImageData(mockBinaryData, mockBuffer);
|
||||
expect(dataUri).toBe('data:image/jpeg;base64,VGVzdCBkYXRh');
|
||||
});
|
||||
|
||||
it('should throw UnsupportedMimeTypeError for non-images', () => {
|
||||
const mockBuffer = Buffer.from('Test data');
|
||||
const mockBinaryData = mock<IBinaryData>({ mimeType: 'text/plain' });
|
||||
|
||||
expect(() => {
|
||||
dataUriFromImageData(mockBinaryData, mockBuffer);
|
||||
}).toThrow(UnsupportedMimeTypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createImageMessage', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
let mockBuffer: Buffer;
|
||||
let mockBinaryData: jest.Mocked<IBinaryData>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockExecuteFunctions();
|
||||
mockBuffer = Buffer.from('Test image data');
|
||||
mockBinaryData = mock<IBinaryData>({ mimeType: 'image/png' });
|
||||
|
||||
// Mock required methods
|
||||
mockContext.getInputData.mockReturnValue([{ binary: { data: mockBinaryData }, json: {} }]);
|
||||
(mockContext.helpers.getBinaryDataBuffer as jest.Mock).mockResolvedValue(mockBuffer);
|
||||
mockContext.getInputConnectionData.mockResolvedValue({});
|
||||
mockContext.getNode.mockReturnValue({ name: 'TestNode' } as INode);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid message type', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'text', // Invalid for this test case
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle image URL messages', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
imageDetail: 'high',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/image.jpg',
|
||||
detail: 'high',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image URL messages with auto detail', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
imageDetail: 'auto',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/image.jpg',
|
||||
detail: undefined, // Auto becomes undefined
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw an error when binary data is missing', async () => {
|
||||
// Set up missing binary data
|
||||
mockContext.getInputData.mockReturnValue([{ json: {} }]); // No binary data
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow('No binary data set.');
|
||||
});
|
||||
|
||||
it('should handle binary image data for regular models', async () => {
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
imageDetail: 'low',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
detail: 'low',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image data differently for GoogleGenerativeAI models', async () => {
|
||||
// Mock a Google model - using our mocked class
|
||||
mockContext.getInputConnectionData.mockResolvedValue(
|
||||
new ChatGoogleGenerativeAI({
|
||||
model: 'gemini-2.5-flash',
|
||||
}),
|
||||
);
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle image data differently for Ollama models', async () => {
|
||||
// Mock an Ollama model - using our mocked class
|
||||
mockContext.getInputConnectionData.mockResolvedValue(new ChatOllama());
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
const result = await createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(HumanMessage);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'data:image/png;base64,VGVzdCBpbWFnZSBkYXRh',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pass through UnsupportedMimeTypeError', async () => {
|
||||
// Mock a non-image mime type
|
||||
mockBinaryData.mimeType = 'application/pdf';
|
||||
|
||||
const message: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageBinary',
|
||||
binaryImageDataKey: 'data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
createImageMessage({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message,
|
||||
}),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import type { AgentAction, AgentFinish } from '@langchain/core/agents';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate, PromptTemplate } from '@langchain/core/prompts';
|
||||
import { FakeLLM, FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
import type { N8nStructuredOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import * as imageUtils from '../methods/imageUtils';
|
||||
import { createPromptTemplate, getAgentStepsParser } from '../methods/promptUtils';
|
||||
import type { MessageTemplate } from '../methods/types';
|
||||
|
||||
jest.mock('../methods/imageUtils', () => ({
|
||||
createImageMessage: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('promptUtils', () => {
|
||||
describe('createPromptTemplate', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a simple prompt template for non-chat models', async () => {
|
||||
const fakeLLM = new FakeLLM({});
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(PromptTemplate);
|
||||
expect(result.inputVariables).toContain('query');
|
||||
});
|
||||
|
||||
it('should create a prompt template with format instructions', async () => {
|
||||
const fakeLLM = new FakeLLM({});
|
||||
const formatInstructions = 'Format your response as JSON';
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeLLM,
|
||||
formatInstructions,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(PromptTemplate);
|
||||
expect(result.inputVariables).toContain('query');
|
||||
|
||||
// Check that format instructions are included in the template
|
||||
const formattedResult = await result.format({ query: 'Test' });
|
||||
expect(formattedResult).toContain(formatInstructions);
|
||||
});
|
||||
|
||||
it('should create a chat prompt template for chat models', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
query: 'Test query',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(ChatPromptTemplate);
|
||||
});
|
||||
|
||||
it('should process text messages correctly', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'SystemMessagePromptTemplate',
|
||||
message: 'You are a helpful assistant',
|
||||
messageType: 'text',
|
||||
},
|
||||
{
|
||||
type: 'AIMessagePromptTemplate',
|
||||
message: 'How can I help you?',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(ChatPromptTemplate);
|
||||
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
expect(formattedMessages).toHaveLength(3); // 2 messages + 1 query
|
||||
expect(formattedMessages[0].content).toBe('You are a helpful assistant');
|
||||
expect(formattedMessages[1].content).toBe('How can I help you?');
|
||||
});
|
||||
|
||||
it('should escape curly braces in messages', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'SystemMessagePromptTemplate',
|
||||
message: 'You are a {helpful} assistant',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
|
||||
// Validate the messages have escaped curly braces
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Tell me a joke',
|
||||
});
|
||||
expect(formattedMessages[0].content).toBe('You are a {helpful} assistant');
|
||||
});
|
||||
|
||||
it('should handle image messages by calling createImageMessage', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const imageMessage: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
};
|
||||
|
||||
// Mock the image message creation
|
||||
const mockHumanMessage = new HumanMessage({
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }],
|
||||
});
|
||||
(imageUtils.createImageMessage as jest.Mock).mockResolvedValue(mockHumanMessage);
|
||||
|
||||
await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages: [imageMessage],
|
||||
query: 'Describe this image',
|
||||
});
|
||||
|
||||
expect(imageUtils.createImageMessage).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
message: imageMessage,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for invalid message types', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
const messages: MessageTemplate[] = [
|
||||
{
|
||||
type: 'InvalidMessageType',
|
||||
message: 'This is an invalid message',
|
||||
messageType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages,
|
||||
query: 'Test query',
|
||||
}),
|
||||
).rejects.toThrow(OperationalError);
|
||||
});
|
||||
|
||||
it('should add the query to an existing human message with content if it exists', async () => {
|
||||
const fakeChatModel = new FakeChatModel({});
|
||||
|
||||
// Create a mock image message with content array
|
||||
const mockHumanMessage = new HumanMessage({
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }],
|
||||
});
|
||||
(imageUtils.createImageMessage as jest.Mock).mockResolvedValue(mockHumanMessage);
|
||||
|
||||
const imageMessage: MessageTemplate = {
|
||||
type: 'HumanMessagePromptTemplate',
|
||||
message: '',
|
||||
messageType: 'imageUrl',
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
};
|
||||
|
||||
const result = await createPromptTemplate({
|
||||
context: mockContext,
|
||||
itemIndex: 0,
|
||||
llm: fakeChatModel,
|
||||
messages: [imageMessage],
|
||||
query: 'Describe this image',
|
||||
});
|
||||
|
||||
// Format the message and check that the query was added to the existing content
|
||||
const formattedMessages = await (result as ChatPromptTemplate).formatMessages({
|
||||
query: 'Describe this image',
|
||||
});
|
||||
expect(formattedMessages).toHaveLength(1);
|
||||
|
||||
// The content should now have the original image and the text query
|
||||
const content = formattedMessages[0].content as any[];
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0].type).toBe('image_url');
|
||||
expect(content[1].type).toBe('text');
|
||||
expect(content[1].text).toContain('Describe this image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentStepsParser', () => {
|
||||
let mockOutputParser: jest.Mocked<N8nStructuredOutputParser>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOutputParser = mock<N8nStructuredOutputParser>({
|
||||
parse: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse string input directly', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const parsedResult = { result: 'success' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser('{"result": "success"}');
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('{"result": "success"}');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse format_final_json_response tool input', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const toolInput = { city: 'Berlin', temperature: 15 };
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput,
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsedResult = { city: 'Berlin', temperature: 15 };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(JSON.stringify(toolInput));
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should handle format_final_json_response with string tool input', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const toolInput = 'simple string';
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput,
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const parsedResult = { text: 'simple string' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('simple string');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with text property', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({ content: [{ type: 'text', text: 'Hello world' }] });
|
||||
|
||||
const parsedResult = { message: 'Hello world' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Hello world');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with content array', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } },
|
||||
],
|
||||
});
|
||||
|
||||
const parsedResult = { message: 'Hello' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Hello');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse BaseMessage with string content', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const message = new HumanMessage({ content: 'Simple text content' });
|
||||
|
||||
const parsedResult = { content: 'Simple text content' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(message);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('Simple text content');
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should parse AgentFinish returnValues', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const agentFinish: AgentFinish = {
|
||||
returnValues: { output: 'Final answer', status: 'success' },
|
||||
log: 'Finished',
|
||||
};
|
||||
|
||||
const parsedResult = { output: 'Final answer', status: 'success' };
|
||||
mockOutputParser.parse.mockResolvedValue(parsedResult);
|
||||
|
||||
const result = await parser(agentFinish);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(JSON.stringify(agentFinish.returnValues));
|
||||
expect(result).toEqual(parsedResult);
|
||||
});
|
||||
|
||||
it('should handle array of agent actions without format_final_json_response', async () => {
|
||||
const parser = getAgentStepsParser(mockOutputParser);
|
||||
const steps: AgentAction[] = [
|
||||
{ tool: 'search', toolInput: { query: 'test' }, log: '' },
|
||||
{ tool: 'calculator', toolInput: { expression: '1+1' }, log: '' },
|
||||
];
|
||||
|
||||
await expect(parser(steps)).rejects.toThrow('Failed to parse agent steps');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { formatResponse } from '../methods/responseFormatter';
|
||||
|
||||
describe('responseFormatter', () => {
|
||||
describe('formatResponse', () => {
|
||||
it('should format string responses', () => {
|
||||
const result = formatResponse('Test response', true);
|
||||
expect(result).toEqual({
|
||||
text: 'Test response',
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim string responses', () => {
|
||||
const result = formatResponse(' Test response with whitespace ', true);
|
||||
expect(result).toEqual({
|
||||
text: 'Test response with whitespace',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array responses', () => {
|
||||
const testArray = [{ item: 1 }, { item: 2 }];
|
||||
const result = formatResponse(testArray, true);
|
||||
expect(result).toEqual({ data: testArray });
|
||||
});
|
||||
|
||||
it('should handle object responses when unwrapping is enabled', () => {
|
||||
const testObject = { key: 'value', nested: { key: 'value' } };
|
||||
const result = formatResponse(testObject, true);
|
||||
expect(result).toEqual(testObject);
|
||||
});
|
||||
|
||||
it('should stringify object responses when unwrapping is disabled', () => {
|
||||
const testObject = { key: 'value', nested: { key: 'value' } };
|
||||
const result = formatResponse(testObject, false);
|
||||
expect(result).toEqual({
|
||||
text: JSON.stringify(testObject),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle primitive non-string responses', () => {
|
||||
const testNumber = 42;
|
||||
const result = formatResponse(testNumber, true);
|
||||
expect(result).toEqual({
|
||||
response: {
|
||||
text: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex object structures when unwrapping is enabled', () => {
|
||||
const complexObject = {
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
address: {
|
||||
street: '123 Main St',
|
||||
city: 'Anytown',
|
||||
},
|
||||
},
|
||||
items: [1, 2, 3],
|
||||
active: true,
|
||||
};
|
||||
|
||||
const result = formatResponse(complexObject, true);
|
||||
expect(result).toEqual(complexObject);
|
||||
});
|
||||
|
||||
it('should stringify complex object structures when unwrapping is disabled', () => {
|
||||
const complexObject = {
|
||||
person: {
|
||||
name: 'John',
|
||||
age: 30,
|
||||
address: {
|
||||
street: '123 Main St',
|
||||
city: 'Anytown',
|
||||
},
|
||||
},
|
||||
items: [1, 2, 3],
|
||||
active: true,
|
||||
};
|
||||
|
||||
const result = formatResponse(complexObject, false);
|
||||
expect(result).toEqual({
|
||||
text: JSON.stringify(complexObject),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { ChainSummarizationV1 } from './V1/ChainSummarizationV1.node';
|
||||
import { ChainSummarizationV2 } from './V2/ChainSummarizationV2.node';
|
||||
|
||||
export class ChainSummarization extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Summarization Chain',
|
||||
name: 'chainSummarization',
|
||||
icon: 'fa:link',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
description: 'Transforms text into a concise summary',
|
||||
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.chainsummarization/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVersion: 2.1,
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new ChainSummarizationV1(baseDescription),
|
||||
2: new ChainSummarizationV2(baseDescription),
|
||||
2.1: new ChainSummarizationV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import type { Document } from '@langchain/core/documents';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { PromptTemplate } from '@langchain/core/prompts';
|
||||
import type { SummarizationChainParams } from '@langchain/classic/chains';
|
||||
import { loadSummarizationChain } from '@langchain/classic/chains';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeTypeBaseDescription,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { N8nBinaryLoader, N8nJsonLoader, getTemplateNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import { REFINE_PROMPT_TEMPLATE, DEFAULT_PROMPT_TEMPLATE } from '../prompt';
|
||||
|
||||
export class ChainSummarizationV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
version: 1,
|
||||
defaults: {
|
||||
name: 'Summarization Chain',
|
||||
color: '#909298',
|
||||
},
|
||||
|
||||
inputs: [
|
||||
NodeConnectionTypes.Main,
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Document',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiDocument,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [],
|
||||
properties: [
|
||||
getTemplateNoticeField(1951),
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
description: 'The type of summarization to run',
|
||||
default: 'map_reduce',
|
||||
options: [
|
||||
{
|
||||
name: 'Map Reduce (Recommended)',
|
||||
value: 'map_reduce',
|
||||
description:
|
||||
'Summarize each document (or chunk) individually, then summarize those summaries',
|
||||
},
|
||||
{
|
||||
name: 'Refine',
|
||||
value: 'refine',
|
||||
description:
|
||||
'Summarize the first document (or chunk). Then update that summary based on the next document (or chunk), and repeat.',
|
||||
},
|
||||
{
|
||||
name: 'Stuff',
|
||||
value: 'stuff',
|
||||
description: 'Pass all documents (or chunks) at once. Ideal for small datasets.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Final Prompt to Combine',
|
||||
name: 'combineMapPrompt',
|
||||
type: 'string',
|
||||
hint: 'The prompt to combine individual summaries',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/type': ['map_reduce'],
|
||||
},
|
||||
},
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Individual Summary Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
hint: 'The prompt to summarize an individual document (or chunk)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/type': ['map_reduce'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/type': ['stuff'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsequent (Refine) Prompt',
|
||||
name: 'refinePrompt',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/type': ['refine'],
|
||||
},
|
||||
},
|
||||
default: REFINE_PROMPT_TEMPLATE,
|
||||
hint: 'The prompt to refine the summary based on the next document (or chunk)',
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Initial Prompt',
|
||||
name: 'refineQuestionPrompt',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/type': ['refine'],
|
||||
},
|
||||
},
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
hint: 'The prompt for the first document (or chunk)',
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
this.logger.debug('Executing Vector Store QA Chain');
|
||||
const type = this.getNodeParameter('type', 0) as 'map_reduce' | 'stuff' | 'refine';
|
||||
|
||||
const model = (await this.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
const documentInput = (await this.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as
|
||||
| N8nJsonLoader
|
||||
| Array<Document<Record<string, unknown>>>;
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {}) as {
|
||||
prompt?: string;
|
||||
refineQuestionPrompt?: string;
|
||||
refinePrompt?: string;
|
||||
combineMapPrompt?: string;
|
||||
};
|
||||
|
||||
const chainArgs: SummarizationChainParams = {
|
||||
type,
|
||||
};
|
||||
|
||||
// Map reduce prompt override
|
||||
if (type === 'map_reduce') {
|
||||
const mapReduceArgs = chainArgs as SummarizationChainParams & {
|
||||
type: 'map_reduce';
|
||||
};
|
||||
if (options.combineMapPrompt) {
|
||||
mapReduceArgs.combineMapPrompt = new PromptTemplate({
|
||||
template: options.combineMapPrompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
if (options.prompt) {
|
||||
mapReduceArgs.combinePrompt = new PromptTemplate({
|
||||
template: options.prompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stuff prompt override
|
||||
if (type === 'stuff') {
|
||||
const stuffArgs = chainArgs as SummarizationChainParams & {
|
||||
type: 'stuff';
|
||||
};
|
||||
if (options.prompt) {
|
||||
stuffArgs.prompt = new PromptTemplate({
|
||||
template: options.prompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refine prompt override
|
||||
if (type === 'refine') {
|
||||
const refineArgs = chainArgs as SummarizationChainParams & {
|
||||
type: 'refine';
|
||||
};
|
||||
|
||||
if (options.refinePrompt) {
|
||||
refineArgs.refinePrompt = new PromptTemplate({
|
||||
template: options.refinePrompt,
|
||||
inputVariables: ['existing_answer', 'text'],
|
||||
});
|
||||
}
|
||||
|
||||
if (options.refineQuestionPrompt) {
|
||||
refineArgs.questionPrompt = new PromptTemplate({
|
||||
template: options.refineQuestionPrompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const chain = loadSummarizationChain(model, chainArgs);
|
||||
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
let processedDocuments: Document[];
|
||||
if (documentInput instanceof N8nJsonLoader || documentInput instanceof N8nBinaryLoader) {
|
||||
processedDocuments = await documentInput.processItem(items[itemIndex], itemIndex);
|
||||
} else {
|
||||
processedDocuments = documentInput;
|
||||
}
|
||||
|
||||
const response = await chain.call({
|
||||
input_documents: processedDocuments,
|
||||
});
|
||||
|
||||
returnData.push({ json: { response } });
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
import type {
|
||||
INodeTypeBaseDescription,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IDataObject,
|
||||
INodeInputConfiguration,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, sleep } from 'n8n-workflow';
|
||||
|
||||
import { getBatchingOptionFields, getTemplateNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import { processItem } from './processItem';
|
||||
import { REFINE_PROMPT_TEMPLATE, DEFAULT_PROMPT_TEMPLATE } from '../prompt';
|
||||
|
||||
/* istanbul ignore next */
|
||||
function getInputs(parameters: IDataObject) {
|
||||
const chunkingMode = parameters?.chunkingMode;
|
||||
const operationMode = parameters?.operationMode;
|
||||
const inputs: INodeInputConfiguration[] = [
|
||||
{ displayName: '', type: 'main' },
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: 'ai_languageModel',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (operationMode === 'documentLoader') {
|
||||
inputs.push({
|
||||
displayName: 'Document',
|
||||
type: 'ai_document',
|
||||
required: true,
|
||||
maxConnections: 1,
|
||||
});
|
||||
return inputs;
|
||||
}
|
||||
|
||||
if (chunkingMode === 'advanced') {
|
||||
inputs.push({
|
||||
displayName: 'Text Splitter',
|
||||
type: 'ai_textSplitter',
|
||||
required: false,
|
||||
maxConnections: 1,
|
||||
});
|
||||
return inputs;
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
export class ChainSummarizationV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
version: [2, 2.1],
|
||||
defaults: {
|
||||
name: 'Summarization Chain',
|
||||
color: '#909298',
|
||||
},
|
||||
|
||||
inputs: `={{ ((parameter) => { ${getInputs.toString()}; return getInputs(parameter) })($parameter) }}`,
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_document: {
|
||||
required: true,
|
||||
displayOptions: { show: { operationMode: ['documentLoader'] } },
|
||||
},
|
||||
ai_textSplitter: {
|
||||
required: false,
|
||||
displayOptions: { show: { chunkingMode: ['advanced'] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
properties: [
|
||||
getTemplateNoticeField(1951),
|
||||
{
|
||||
displayName: 'Data to Summarize',
|
||||
name: 'operationMode',
|
||||
noDataExpression: true,
|
||||
type: 'options',
|
||||
description: 'How to pass data into the summarization chain',
|
||||
default: 'nodeInputJson',
|
||||
options: [
|
||||
{
|
||||
name: 'Use Node Input (JSON)',
|
||||
value: 'nodeInputJson',
|
||||
description: 'Summarize the JSON data coming into this node from the previous one',
|
||||
},
|
||||
{
|
||||
name: 'Use Node Input (Binary)',
|
||||
value: 'nodeInputBinary',
|
||||
description: 'Summarize the binary data coming into this node from the previous one',
|
||||
},
|
||||
{
|
||||
name: 'Use Document Loader',
|
||||
value: 'documentLoader',
|
||||
description: 'Use a loader sub-node with more configuration options',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Chunking Strategy',
|
||||
name: 'chunkingMode',
|
||||
noDataExpression: true,
|
||||
type: 'options',
|
||||
description: 'Chunk splitting strategy',
|
||||
default: 'simple',
|
||||
options: [
|
||||
{
|
||||
name: 'Simple (Define Below)',
|
||||
value: 'simple',
|
||||
},
|
||||
{
|
||||
name: 'Advanced',
|
||||
value: 'advanced',
|
||||
description: 'Use a splitter sub-node with more configuration options',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operationMode': ['nodeInputJson', 'nodeInputBinary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Characters Per Chunk',
|
||||
name: 'chunkSize',
|
||||
description:
|
||||
'Controls the max size (in terms of number of characters) of the final document chunk',
|
||||
type: 'number',
|
||||
default: 1000,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/chunkingMode': ['simple'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Chunk Overlap (Characters)',
|
||||
name: 'chunkOverlap',
|
||||
type: 'number',
|
||||
description: 'Specifies how much characters overlap there should be between chunks',
|
||||
default: 200,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/chunkingMode': ['simple'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'binaryDataKey',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
description:
|
||||
'The name of the field in the agent or chain’s input that contains the binary file to be processed',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operationMode': ['nodeInputBinary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Summarization Method and Prompts',
|
||||
name: 'summarizationMethodAndPrompts',
|
||||
type: 'fixedCollection',
|
||||
default: {
|
||||
values: {
|
||||
summarizationMethod: 'map_reduce',
|
||||
prompt: DEFAULT_PROMPT_TEMPLATE,
|
||||
combineMapPrompt: DEFAULT_PROMPT_TEMPLATE,
|
||||
},
|
||||
},
|
||||
placeholder: 'Add Option',
|
||||
typeOptions: {},
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Summarization Method',
|
||||
name: 'summarizationMethod',
|
||||
type: 'options',
|
||||
description: 'The type of summarization to run',
|
||||
default: 'map_reduce',
|
||||
options: [
|
||||
{
|
||||
name: 'Map Reduce (Recommended)',
|
||||
value: 'map_reduce',
|
||||
description:
|
||||
'Summarize each document (or chunk) individually, then summarize those summaries',
|
||||
},
|
||||
{
|
||||
name: 'Refine',
|
||||
value: 'refine',
|
||||
description:
|
||||
'Summarize the first document (or chunk). Then update that summary based on the next document (or chunk), and repeat.',
|
||||
},
|
||||
{
|
||||
name: 'Stuff',
|
||||
value: 'stuff',
|
||||
description:
|
||||
'Pass all documents (or chunks) at once. Ideal for small datasets.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Individual Summary Prompt',
|
||||
name: 'combineMapPrompt',
|
||||
type: 'string',
|
||||
hint: 'The prompt to summarize an individual document (or chunk)',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/options.summarizationMethodAndPrompts.values.summarizationMethod': [
|
||||
'stuff',
|
||||
'refine',
|
||||
],
|
||||
},
|
||||
},
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
typeOptions: {
|
||||
rows: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Final Prompt to Combine',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
hint: 'The prompt to combine individual summaries',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/options.summarizationMethodAndPrompts.values.summarizationMethod': [
|
||||
'stuff',
|
||||
'refine',
|
||||
],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
rows: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/options.summarizationMethodAndPrompts.values.summarizationMethod': [
|
||||
'refine',
|
||||
'map_reduce',
|
||||
],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
rows: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsequent (Refine) Prompt',
|
||||
name: 'refinePrompt',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/options.summarizationMethodAndPrompts.values.summarizationMethod': [
|
||||
'stuff',
|
||||
'map_reduce',
|
||||
],
|
||||
},
|
||||
},
|
||||
default: REFINE_PROMPT_TEMPLATE,
|
||||
hint: 'The prompt to refine the summary based on the next document (or chunk)',
|
||||
typeOptions: {
|
||||
rows: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Initial Prompt',
|
||||
name: 'refineQuestionPrompt',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/options.summarizationMethodAndPrompts.values.summarizationMethod': [
|
||||
'stuff',
|
||||
'map_reduce',
|
||||
],
|
||||
},
|
||||
},
|
||||
default: DEFAULT_PROMPT_TEMPLATE,
|
||||
hint: 'The prompt for the first document (or chunk)',
|
||||
typeOptions: {
|
||||
rows: 9,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
getBatchingOptionFields({
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.1 } }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
this.logger.debug('Executing Summarization Chain V2');
|
||||
const operationMode = this.getNodeParameter('operationMode', 0, 'nodeInputJson') as
|
||||
| 'nodeInputJson'
|
||||
| 'nodeInputBinary'
|
||||
| 'documentLoader';
|
||||
const chunkingMode = this.getNodeParameter('chunkingMode', 0, 'simple') as
|
||||
| 'simple'
|
||||
| 'advanced';
|
||||
|
||||
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 >= 2.1 && batchSize > 1) {
|
||||
// Batch processing
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(async (item, batchItemIndex) => {
|
||||
const itemIndex = i + batchItemIndex;
|
||||
return await processItem(this, itemIndex, item, operationMode, chunkingMode);
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
batchResults.forEach((response, index) => {
|
||||
if (response.status === 'rejected') {
|
||||
const error = response.reason as Error;
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: i + index },
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const output = response.value;
|
||||
returnData.push({ json: { output } });
|
||||
}
|
||||
});
|
||||
|
||||
// Add delay between batches if not the last batch
|
||||
if (i + batchSize < items.length && delayBetweenBatches > 0) {
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
try {
|
||||
const response = await processItem(
|
||||
this,
|
||||
itemIndex,
|
||||
items[itemIndex],
|
||||
operationMode,
|
||||
chunkingMode,
|
||||
);
|
||||
returnData.push({ json: { response } });
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Document } from '@langchain/core/documents';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { ChainValues } from '@langchain/core/utils/types';
|
||||
import { RecursiveCharacterTextSplitter, type TextSplitter } from '@langchain/textsplitters';
|
||||
import { loadSummarizationChain } from '@langchain/classic/chains';
|
||||
import { type IExecuteFunctions, type INodeExecutionData, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { N8nBinaryLoader, N8nJsonLoader } from '@n8n/ai-utilities';
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
import { getChainPromptsArgs } from '../helpers';
|
||||
|
||||
export async function processItem(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
item: INodeExecutionData,
|
||||
operationMode: string,
|
||||
chunkingMode: 'simple' | 'advanced' | 'none',
|
||||
): Promise<ChainValues | undefined> {
|
||||
const model = (await ctx.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
const summarizationMethodAndPrompts = ctx.getNodeParameter(
|
||||
'options.summarizationMethodAndPrompts.values',
|
||||
itemIndex,
|
||||
{},
|
||||
) as {
|
||||
prompt?: string;
|
||||
refineQuestionPrompt?: string;
|
||||
refinePrompt?: string;
|
||||
summarizationMethod: 'map_reduce' | 'stuff' | 'refine';
|
||||
combineMapPrompt?: string;
|
||||
};
|
||||
|
||||
const chainArgs = getChainPromptsArgs(
|
||||
summarizationMethodAndPrompts.summarizationMethod ?? 'map_reduce',
|
||||
summarizationMethodAndPrompts,
|
||||
);
|
||||
|
||||
const chain = loadSummarizationChain(model, chainArgs);
|
||||
|
||||
let processedDocuments: Document[];
|
||||
|
||||
// Use dedicated document loader input to load documents
|
||||
if (operationMode === 'documentLoader') {
|
||||
const documentInput = (await ctx.getInputConnectionData(NodeConnectionTypes.AiDocument, 0)) as
|
||||
| N8nJsonLoader
|
||||
| Array<Document<Record<string, unknown>>>;
|
||||
|
||||
const isN8nLoader =
|
||||
documentInput instanceof N8nJsonLoader || documentInput instanceof N8nBinaryLoader;
|
||||
|
||||
processedDocuments = isN8nLoader
|
||||
? await documentInput.processItem(item, itemIndex)
|
||||
: documentInput;
|
||||
|
||||
return await chain.withConfig(getTracingConfig(ctx)).invoke({
|
||||
input_documents: processedDocuments,
|
||||
});
|
||||
} else if (['nodeInputJson', 'nodeInputBinary'].includes(operationMode)) {
|
||||
// Take the input and use binary or json loader
|
||||
let textSplitter: TextSplitter | undefined;
|
||||
|
||||
switch (chunkingMode) {
|
||||
// In simple mode we use recursive character splitter with default settings
|
||||
case 'simple':
|
||||
const chunkSize = ctx.getNodeParameter('chunkSize', itemIndex, 1000) as number;
|
||||
const chunkOverlap = ctx.getNodeParameter('chunkOverlap', itemIndex, 200) as number;
|
||||
|
||||
textSplitter = new RecursiveCharacterTextSplitter({ chunkOverlap, chunkSize });
|
||||
break;
|
||||
|
||||
// In advanced mode user can connect text splitter node so we just retrieve it
|
||||
case 'advanced':
|
||||
textSplitter = (await ctx.getInputConnectionData(NodeConnectionTypes.AiTextSplitter, 0)) as
|
||||
| TextSplitter
|
||||
| undefined;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
let processor: N8nJsonLoader | N8nBinaryLoader;
|
||||
if (operationMode === 'nodeInputBinary') {
|
||||
const binaryDataKey = ctx.getNodeParameter(
|
||||
'options.binaryDataKey',
|
||||
itemIndex,
|
||||
'data',
|
||||
) as string;
|
||||
processor = new N8nBinaryLoader(ctx, 'options.', binaryDataKey, textSplitter);
|
||||
} else {
|
||||
processor = new N8nJsonLoader(ctx, 'options.', textSplitter);
|
||||
}
|
||||
|
||||
const processedItem = await processor.processItem(item, itemIndex);
|
||||
return await chain.invoke(
|
||||
{
|
||||
input_documents: processedItem,
|
||||
},
|
||||
{ signal: ctx.getExecutionCancelSignal() },
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { PromptTemplate } from '@langchain/core/prompts';
|
||||
import type { SummarizationChainParams } from '@langchain/classic/chains';
|
||||
interface ChainTypeOptions {
|
||||
combineMapPrompt?: string;
|
||||
prompt?: string;
|
||||
refinePrompt?: string;
|
||||
refineQuestionPrompt?: string;
|
||||
}
|
||||
|
||||
export function getChainPromptsArgs(
|
||||
type: 'stuff' | 'map_reduce' | 'refine',
|
||||
options: ChainTypeOptions,
|
||||
) {
|
||||
const chainArgs: SummarizationChainParams = {
|
||||
type,
|
||||
};
|
||||
// Map reduce prompt override
|
||||
if (type === 'map_reduce') {
|
||||
const mapReduceArgs = chainArgs as SummarizationChainParams & {
|
||||
type: 'map_reduce';
|
||||
};
|
||||
if (options.combineMapPrompt) {
|
||||
mapReduceArgs.combineMapPrompt = new PromptTemplate({
|
||||
template: options.combineMapPrompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
if (options.prompt) {
|
||||
mapReduceArgs.combinePrompt = new PromptTemplate({
|
||||
template: options.prompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stuff prompt override
|
||||
if (type === 'stuff') {
|
||||
const stuffArgs = chainArgs as SummarizationChainParams & {
|
||||
type: 'stuff';
|
||||
};
|
||||
if (options.prompt) {
|
||||
stuffArgs.prompt = new PromptTemplate({
|
||||
template: options.prompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refine prompt override
|
||||
if (type === 'refine') {
|
||||
const refineArgs = chainArgs as SummarizationChainParams & {
|
||||
type: 'refine';
|
||||
};
|
||||
|
||||
if (options.refinePrompt) {
|
||||
refineArgs.refinePrompt = new PromptTemplate({
|
||||
template: options.refinePrompt,
|
||||
inputVariables: ['existing_answer', 'text'],
|
||||
});
|
||||
}
|
||||
|
||||
if (options.refineQuestionPrompt) {
|
||||
refineArgs.questionPrompt = new PromptTemplate({
|
||||
template: options.refineQuestionPrompt,
|
||||
inputVariables: ['text'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return chainArgs;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const REFINE_PROMPT_TEMPLATE = `Your job is to produce a final summary
|
||||
We have provided an existing summary up to a certain point: "{existing_answer}"
|
||||
We have the opportunity to refine the existing summary
|
||||
(only if needed) with some more context below.
|
||||
------------
|
||||
"{text}"
|
||||
------------
|
||||
|
||||
Given the new context, refine the original summary
|
||||
If the context isn't useful, return the original summary.
|
||||
|
||||
REFINED SUMMARY:`;
|
||||
|
||||
export const DEFAULT_PROMPT_TEMPLATE = `Write a concise summary of the following:
|
||||
|
||||
|
||||
"{text}"
|
||||
|
||||
|
||||
CONCISE SUMMARY:`;
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { OutputFixingParser, StructuredOutputParser } from '@langchain/classic/output_parsers';
|
||||
import { jsonParse, NodeConnectionTypes, NodeOperationError, sleep } from 'n8n-workflow';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
buildJsonSchemaExampleNotice,
|
||||
inputSchemaField,
|
||||
jsonSchemaExampleField,
|
||||
schemaTypeField,
|
||||
} from '@utils/descriptions';
|
||||
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
|
||||
import { getBatchingOptionFields } from '@n8n/ai-utilities';
|
||||
|
||||
import { SYSTEM_PROMPT_TEMPLATE } from './constants';
|
||||
import { makeZodSchemaFromAttributes } from './helpers';
|
||||
import { processItem } from './processItem';
|
||||
import type { AttributeDefinition } from './types';
|
||||
|
||||
export class InformationExtractor implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Information Extractor',
|
||||
name: 'informationExtractor',
|
||||
icon: 'fa:project-diagram',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2],
|
||||
defaultVersion: 1.2,
|
||||
description: 'Extract information from text in a structured format',
|
||||
codex: {
|
||||
alias: ['NER', 'parse', 'parsing', 'JSON', 'data extraction', 'structured'],
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Chains', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.information-extractor/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
name: 'Information Extractor',
|
||||
},
|
||||
inputs: [
|
||||
{ displayName: '', type: NodeConnectionTypes.Main },
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The text to extract information from',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
...schemaTypeField,
|
||||
description: 'How to specify the schema for the desired output',
|
||||
options: [
|
||||
{
|
||||
name: 'From Attribute Descriptions',
|
||||
value: 'fromAttributes',
|
||||
description:
|
||||
'Extract specific attributes from the text based on types and descriptions',
|
||||
} as INodePropertyOptions,
|
||||
...(schemaTypeField.options as INodePropertyOptions[]),
|
||||
],
|
||||
default: 'fromAttributes',
|
||||
},
|
||||
{
|
||||
...jsonSchemaExampleField,
|
||||
default: `{
|
||||
"state": "California",
|
||||
"cities": ["Los Angeles", "San Francisco", "San Diego"]
|
||||
}`,
|
||||
},
|
||||
buildJsonSchemaExampleNotice({
|
||||
showExtraProps: {
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
...inputSchemaField,
|
||||
default: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string"
|
||||
},
|
||||
"cities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
displayName: 'Attributes',
|
||||
name: 'attributes',
|
||||
placeholder: 'Add Attribute',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
schemaType: ['fromAttributes'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'attributes',
|
||||
displayName: 'Attribute List',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Attribute to extract',
|
||||
placeholder: 'e.g. company_name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
description: 'Data type of the attribute',
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Boolean',
|
||||
value: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'Date',
|
||||
value: 'date',
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
value: 'number',
|
||||
},
|
||||
{
|
||||
name: 'String',
|
||||
value: 'string',
|
||||
},
|
||||
],
|
||||
default: 'string',
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Describe your attribute',
|
||||
placeholder: 'Add description for the attribute',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Required',
|
||||
name: 'required',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether attribute is required',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'System Prompt Template',
|
||||
name: 'systemPromptTemplate',
|
||||
type: 'string',
|
||||
default: SYSTEM_PROMPT_TEMPLATE,
|
||||
description: 'String to use directly as the system prompt template',
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
getBatchingOptionFields({
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
const llm = (await this.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
const schemaType = this.getNodeParameter('schemaType', 0, '') as
|
||||
| 'fromAttributes'
|
||||
| 'fromJson'
|
||||
| 'manual';
|
||||
|
||||
let parser: OutputFixingParser<object>;
|
||||
|
||||
if (schemaType === 'fromAttributes') {
|
||||
const attributes = this.getNodeParameter(
|
||||
'attributes.attributes',
|
||||
0,
|
||||
[],
|
||||
) as AttributeDefinition[];
|
||||
|
||||
if (attributes.length === 0) {
|
||||
throw new NodeOperationError(this.getNode(), 'At least one attribute must be specified');
|
||||
}
|
||||
|
||||
parser = OutputFixingParser.fromLLM(
|
||||
llm,
|
||||
StructuredOutputParser.fromZodSchema(makeZodSchemaFromAttributes(attributes)),
|
||||
);
|
||||
} else {
|
||||
let jsonSchema: JSONSchema7;
|
||||
|
||||
if (schemaType === 'fromJson') {
|
||||
const jsonExample = this.getNodeParameter('jsonSchemaExample', 0, '') as string;
|
||||
// Enforce all fields to be required in the generated schema if the node version is 1.2 or higher
|
||||
const jsonExampleAllFieldsRequired = this.getNode().typeVersion >= 1.2;
|
||||
|
||||
jsonSchema = generateSchemaFromExample(jsonExample, jsonExampleAllFieldsRequired);
|
||||
} else {
|
||||
const inputSchema = this.getNodeParameter('inputSchema', 0, '') as string;
|
||||
jsonSchema = jsonParse<JSONSchema7>(inputSchema);
|
||||
}
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod<z.ZodSchema<object>>(jsonSchema);
|
||||
|
||||
parser = OutputFixingParser.fromLLM(llm, StructuredOutputParser.fromZodSchema(zodSchema));
|
||||
}
|
||||
|
||||
const resultData: 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.1 && batchSize >= 1) {
|
||||
// Batch processing
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(async (_item, batchItemIndex) => {
|
||||
const itemIndex = i + batchItemIndex;
|
||||
return await processItem(this, itemIndex, llm, parser);
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
batchResults.forEach((response, index) => {
|
||||
if (response.status === 'rejected') {
|
||||
const error = response.reason as Error;
|
||||
if (this.continueOnFail()) {
|
||||
resultData.push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: i + index },
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
throw new NodeOperationError(this.getNode(), error.message);
|
||||
}
|
||||
}
|
||||
const output = response.value;
|
||||
resultData.push({ json: { output } });
|
||||
});
|
||||
|
||||
// Add delay between batches if not the last batch
|
||||
if (i + batchSize < items.length && delayBetweenBatches > 0) {
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sequential processing
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
try {
|
||||
const output = await processItem(this, itemIndex, llm, parser);
|
||||
resultData.push({ json: { output } });
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
resultData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [resultData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SYSTEM_PROMPT_TEMPLATE = `You are an expert extraction algorithm.
|
||||
Only extract relevant information from the text.
|
||||
If you do not know the value of an attribute asked to extract, you may omit the attribute's value.`;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AttributeDefinition } from './types';
|
||||
|
||||
function makeAttributeSchema(attributeDefinition: AttributeDefinition, required: boolean = true) {
|
||||
let schema: z.ZodTypeAny;
|
||||
|
||||
if (attributeDefinition.type === 'string') {
|
||||
schema = z.string();
|
||||
} else if (attributeDefinition.type === 'number') {
|
||||
schema = z.number();
|
||||
} else if (attributeDefinition.type === 'boolean') {
|
||||
schema = z.boolean();
|
||||
} else if (attributeDefinition.type === 'date') {
|
||||
schema = z.string().date();
|
||||
} else {
|
||||
schema = z.unknown();
|
||||
}
|
||||
|
||||
if (!required) {
|
||||
schema = schema.optional();
|
||||
}
|
||||
|
||||
return schema.describe(attributeDefinition.description);
|
||||
}
|
||||
|
||||
export function makeZodSchemaFromAttributes(attributes: AttributeDefinition[]) {
|
||||
const schemaEntries = attributes.map((attr) => [
|
||||
attr.name,
|
||||
makeAttributeSchema(attr, attr.required),
|
||||
]);
|
||||
|
||||
return z.object(Object.fromEntries(schemaEntries));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts';
|
||||
import type { OutputFixingParser } from '@langchain/classic/output_parsers';
|
||||
import { NodeOperationError, type IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
import { SYSTEM_PROMPT_TEMPLATE } from './constants';
|
||||
|
||||
export async function processItem(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
llm: BaseLanguageModel,
|
||||
parser: OutputFixingParser<object>,
|
||||
) {
|
||||
const input = ctx.getNodeParameter('text', itemIndex) as string;
|
||||
if (!input?.trim()) {
|
||||
throw new NodeOperationError(ctx.getNode(), `Text for item ${itemIndex} is not defined`, {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
const inputPrompt = new HumanMessage(input);
|
||||
|
||||
const options = ctx.getNodeParameter('options', itemIndex, {}) as {
|
||||
systemPromptTemplate?: string;
|
||||
};
|
||||
|
||||
const escapedTemplate = (options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE).replace(
|
||||
/[{}]/g,
|
||||
(match) => match + match,
|
||||
);
|
||||
|
||||
const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate(
|
||||
`${escapedTemplate}
|
||||
{format_instructions}`,
|
||||
);
|
||||
|
||||
const messages = [
|
||||
await systemPromptTemplate.format({
|
||||
format_instructions: parser.getFormatInstructions(),
|
||||
}),
|
||||
inputPrompt,
|
||||
];
|
||||
const prompt = ChatPromptTemplate.fromMessages(messages);
|
||||
const chain = prompt.pipe(llm).pipe(parser).withConfig(getTracingConfig(ctx));
|
||||
|
||||
return await chain.invoke(messages);
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { FakeListChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import get from 'lodash/get';
|
||||
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import { makeZodSchemaFromAttributes } from '../helpers';
|
||||
import { InformationExtractor } from '../InformationExtractor.node';
|
||||
import type { AttributeDefinition } from '../types';
|
||||
|
||||
const mockPersonAttributes: AttributeDefinition[] = [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'The name of the person',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
description: 'The age of the person',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockPersonAttributesRequired: AttributeDefinition[] = [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'The name of the person',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
description: 'The age of the person',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
function formatFakeLlmResponse(object: Record<string, any>) {
|
||||
return `\`\`\`json\n${JSON.stringify(object, null, 2)}\n\`\`\``;
|
||||
}
|
||||
|
||||
const createExecuteFunctionsMock = (
|
||||
parameters: IDataObject,
|
||||
fakeLlm: BaseLanguageModel,
|
||||
inputData = [{ json: {} }],
|
||||
) => {
|
||||
const nodeParameters = parameters;
|
||||
|
||||
return {
|
||||
getNodeParameter(parameter: string) {
|
||||
return get(nodeParameters, parameter);
|
||||
},
|
||||
getNode() {
|
||||
return {
|
||||
typeVersion: 1.1,
|
||||
};
|
||||
},
|
||||
getInputConnectionData() {
|
||||
return fakeLlm;
|
||||
},
|
||||
getInputData() {
|
||||
return inputData;
|
||||
},
|
||||
getWorkflow() {
|
||||
return {
|
||||
name: 'Test Workflow',
|
||||
};
|
||||
},
|
||||
getExecutionId() {
|
||||
return 'test_execution_id';
|
||||
},
|
||||
continueOnFail() {
|
||||
return false;
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
};
|
||||
|
||||
describe('InformationExtractor', () => {
|
||||
describe('Schema Generation', () => {
|
||||
it('should generate a schema from attribute descriptions with optional fields', async () => {
|
||||
const schema = makeZodSchemaFromAttributes(mockPersonAttributes);
|
||||
|
||||
expect(schema.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 });
|
||||
expect(schema.parse({ name: 'John' })).toEqual({ name: 'John' });
|
||||
expect(schema.parse({ age: 30 })).toEqual({ age: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single Item Processing with JSON Schema from Example', () => {
|
||||
it('should extract information using JSON schema from example - version 1.2 (required fields)', async () => {
|
||||
const node = new InformationExtractor();
|
||||
const inputData = [
|
||||
{
|
||||
json: { text: 'John lives in California and has visited Los Angeles and San Francisco' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
text: 'John lives in California and has visited Los Angeles and San Francisco',
|
||||
schemaType: 'fromJson',
|
||||
jsonSchemaExample: JSON.stringify({
|
||||
state: 'California',
|
||||
cities: ['Los Angeles', 'San Francisco'],
|
||||
}),
|
||||
options: {
|
||||
systemPromptTemplate: '',
|
||||
},
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({
|
||||
state: 'California',
|
||||
cities: ['Los Angeles', 'San Francisco'],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
// Mock version 1.2 to test required fields behavior
|
||||
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.2 });
|
||||
|
||||
const response = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(response).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
output: {
|
||||
state: 'California',
|
||||
cities: ['Los Angeles', 'San Francisco'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract information using JSON schema from example - version 1.1 (optional fields)', async () => {
|
||||
const node = new InformationExtractor();
|
||||
const inputData = [{ json: { text: 'John lives in California' } }];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
text: 'John lives in California',
|
||||
schemaType: 'fromJson',
|
||||
jsonSchemaExample: JSON.stringify({
|
||||
state: 'California',
|
||||
cities: ['Los Angeles', 'San Francisco'],
|
||||
}),
|
||||
options: {
|
||||
systemPromptTemplate: '',
|
||||
},
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({
|
||||
state: 'California',
|
||||
// cities field missing - should be allowed in v1.1
|
||||
}),
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
// Mock version 1.1 to test optional fields behavior
|
||||
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.1 });
|
||||
|
||||
const response = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(response).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
output: {
|
||||
state: 'California',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error for incomplete model output in version 1.2 (required fields)', async () => {
|
||||
const node = new InformationExtractor();
|
||||
const inputData = [{ json: { text: 'John lives in California' } }];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
text: 'John lives in California',
|
||||
schemaType: 'fromJson',
|
||||
jsonSchemaExample: JSON.stringify({
|
||||
state: 'California',
|
||||
cities: ['Los Angeles', 'San Francisco'],
|
||||
zipCode: '90210',
|
||||
}),
|
||||
options: {
|
||||
systemPromptTemplate: '',
|
||||
},
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({
|
||||
state: 'California',
|
||||
// Missing cities and zipCode - should fail in v1.2 since all fields are required
|
||||
}),
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.2 });
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should extract information using complex nested JSON schema from example', async () => {
|
||||
const node = new InformationExtractor();
|
||||
const inputData = [
|
||||
{
|
||||
json: {
|
||||
text: 'John Doe works at Acme Corp as a Software Engineer with 5 years experience',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const complexSchema = {
|
||||
person: {
|
||||
name: 'John Doe',
|
||||
company: {
|
||||
name: 'Acme Corp',
|
||||
position: 'Software Engineer',
|
||||
},
|
||||
},
|
||||
experience: {
|
||||
years: 5,
|
||||
skills: ['JavaScript', 'TypeScript'],
|
||||
},
|
||||
};
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
text: 'John Doe works at Acme Corp as a Software Engineer with 5 years experience',
|
||||
schemaType: 'fromJson',
|
||||
jsonSchemaExample: JSON.stringify(complexSchema),
|
||||
options: {
|
||||
systemPromptTemplate: '',
|
||||
},
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({
|
||||
person: {
|
||||
name: 'John Doe',
|
||||
company: {
|
||||
name: 'Acme Corp',
|
||||
position: 'Software Engineer',
|
||||
},
|
||||
},
|
||||
experience: {
|
||||
years: 5,
|
||||
skills: ['JavaScript', 'TypeScript'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
mockExecuteFunctions.getNode = () => mock<INode>({ typeVersion: 1.2 });
|
||||
|
||||
const response = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(response[0][0].json.output).toMatchObject({
|
||||
person: {
|
||||
name: 'John Doe',
|
||||
company: {
|
||||
name: 'Acme Corp',
|
||||
position: 'Software Engineer',
|
||||
},
|
||||
},
|
||||
experience: {
|
||||
years: 5,
|
||||
skills: expect.arrayContaining(['JavaScript', 'TypeScript']),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
it('should process multiple items in batches', async () => {
|
||||
const node = new InformationExtractor();
|
||||
const inputData = [
|
||||
{ json: { text: 'John is 30 years old' } },
|
||||
{ json: { text: 'Alice is 25 years old' } },
|
||||
{ json: { text: 'Bob is 40 years old' } },
|
||||
];
|
||||
|
||||
const response = await node.execute.call(
|
||||
createExecuteFunctionsMock(
|
||||
{
|
||||
text: 'John is 30 years old',
|
||||
attributes: {
|
||||
attributes: mockPersonAttributes,
|
||||
},
|
||||
options: {
|
||||
batching: {
|
||||
batchSize: 2,
|
||||
delayBetweenBatches: 0,
|
||||
},
|
||||
},
|
||||
schemaType: 'fromAttributes',
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({ name: 'John', age: 30 }),
|
||||
formatFakeLlmResponse({ name: 'Alice', age: 25 }),
|
||||
formatFakeLlmResponse({ name: 'Bob', age: 40 }),
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
),
|
||||
);
|
||||
|
||||
expect(response).toEqual([
|
||||
[
|
||||
{ json: { output: { name: 'John', age: 30 } } },
|
||||
{ json: { output: { name: 'Alice', age: 25 } } },
|
||||
{ json: { output: { name: 'Bob', age: 40 } } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle errors in batch processing', async () => {
|
||||
const node = new InformationExtractor();
|
||||
const inputData = [
|
||||
{ json: { text: 'John is 30 years old' } },
|
||||
{ json: { text: 'Invalid text' } },
|
||||
{ json: { text: 'Bob is 40 years old' } },
|
||||
];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
text: 'John is 30 years old',
|
||||
attributes: {
|
||||
attributes: mockPersonAttributesRequired,
|
||||
},
|
||||
options: {
|
||||
batching: {
|
||||
batchSize: 2,
|
||||
delayBetweenBatches: 0,
|
||||
},
|
||||
},
|
||||
schemaType: 'fromAttributes',
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({ name: 'John', age: 30 }),
|
||||
formatFakeLlmResponse({ name: 'Invalid' }), // Missing required age
|
||||
formatFakeLlmResponse({ name: 'Invalid' }), // Missing required age on retry
|
||||
formatFakeLlmResponse({ name: 'Bob', age: 40 }),
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
mockExecuteFunctions.continueOnFail = () => true;
|
||||
|
||||
const response = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(response[0]).toHaveLength(3);
|
||||
expect(response[0][0]).toEqual({ json: { output: { name: 'John', age: 30 } } });
|
||||
expect(response[0][1]).toEqual({
|
||||
json: { error: expect.stringContaining('Failed to parse') },
|
||||
pairedItem: { item: 1 },
|
||||
});
|
||||
expect(response[0][2]).toEqual({ json: { output: { name: 'Bob', age: 40 } } });
|
||||
});
|
||||
|
||||
it('should throw error if batch processing fails and continueOnFail is false', async () => {
|
||||
const node = new InformationExtractor();
|
||||
const inputData = [
|
||||
{ json: { text: 'John is 30 years old' } },
|
||||
{ json: { text: 'Invalid text' } },
|
||||
{ json: { text: 'Bob is 40 years old' } },
|
||||
];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
text: 'John is 30 years old',
|
||||
attributes: {
|
||||
attributes: mockPersonAttributesRequired,
|
||||
},
|
||||
options: {
|
||||
batching: {
|
||||
batchSize: 2,
|
||||
delayBetweenBatches: 0,
|
||||
},
|
||||
},
|
||||
schemaType: 'fromAttributes',
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({ name: 'John', age: 30 }),
|
||||
formatFakeLlmResponse({ name: 'Invalid' }), // Missing required age
|
||||
formatFakeLlmResponse({ name: 'Invalid' }), // Missing required age on retry
|
||||
formatFakeLlmResponse({ name: 'Bob', age: 40 }),
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow('Failed to parse');
|
||||
});
|
||||
});
|
||||
});
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { FakeLLM, FakeListChatModel } from '@langchain/core/utils/testing';
|
||||
import { OutputFixingParser, StructuredOutputParser } from '@langchain/classic/output_parsers';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { makeZodSchemaFromAttributes } from '../helpers';
|
||||
import { processItem } from '../processItem';
|
||||
import type { AttributeDefinition } from '../types';
|
||||
|
||||
jest.mock('@utils/tracing', () => ({
|
||||
getTracingConfig: () => ({}),
|
||||
}));
|
||||
|
||||
const mockPersonAttributes: AttributeDefinition[] = [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'The name of the person',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
description: 'The age of the person',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockPersonAttributesRequired: AttributeDefinition[] = [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'The name of the person',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
description: 'The age of the person',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
function formatFakeLlmResponse(object: Record<string, any>) {
|
||||
return `\`\`\`json\n${JSON.stringify(object, null, 2)}\n\`\`\``;
|
||||
}
|
||||
|
||||
describe('processItem', () => {
|
||||
it('should process a single item and return extracted attributes', async () => {
|
||||
const mockExecuteFunctions = {
|
||||
getNodeParameter: (param: string) => {
|
||||
if (param === 'text') return 'John is 30 years old';
|
||||
if (param === 'options') return {};
|
||||
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 throw error if input is undefined or empty', async () => {
|
||||
const mockExecuteFunctions = {
|
||||
getNodeParameter: (param: string, itemIndex: number) => {
|
||||
if (param === 'text') {
|
||||
if (itemIndex === 0) return undefined;
|
||||
if (itemIndex === 1) return '';
|
||||
if (itemIndex === 2) return ' ';
|
||||
return null;
|
||||
}
|
||||
if (param === 'options') return {};
|
||||
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)),
|
||||
);
|
||||
|
||||
for (let itemIndex = 0; itemIndex < 4; itemIndex++) {
|
||||
await expect(
|
||||
processItem(mockExecuteFunctions as any, itemIndex, llm, parser),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use custom system prompt template if provided', async () => {
|
||||
const customTemplate = 'Custom template {format_instructions}';
|
||||
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 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) => {
|
||||
if (param === 'text') return 'John is 30 years old';
|
||||
if (param === 'options') return {};
|
||||
return undefined;
|
||||
},
|
||||
getNode: () => ({ typeVersion: 1.1 }),
|
||||
};
|
||||
|
||||
const llm = new FakeListChatModel({
|
||||
responses: [
|
||||
formatFakeLlmResponse({ name: 'John', age: '30' }), // Wrong type
|
||||
formatFakeLlmResponse({ name: 'John', age: 30 }), // Correct type
|
||||
],
|
||||
});
|
||||
const parser = OutputFixingParser.fromLLM(
|
||||
llm,
|
||||
StructuredOutputParser.fromZodSchema(
|
||||
makeZodSchemaFromAttributes(mockPersonAttributesRequired),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await processItem(mockExecuteFunctions as any, 0, llm, parser);
|
||||
|
||||
expect(result).toEqual({ name: 'John', age: 30 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'date';
|
||||
required: boolean;
|
||||
}
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { SystemMessagePromptTemplate, ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import { OutputFixingParser, StructuredOutputParser } from '@langchain/classic/output_parsers';
|
||||
import { NodeConnectionTypes, NodeOperationError, sleep } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getBatchingOptionFields } from '@n8n/ai-utilities';
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT_TEMPLATE =
|
||||
'You are highly intelligent and accurate sentiment analyzer. Analyze the sentiment of the provided text. Categorize it into one of the following: {categories}. Use the provided formatting instructions. Only output the JSON.';
|
||||
|
||||
const DEFAULT_CATEGORIES = 'Positive, Neutral, Negative';
|
||||
const configuredOutputs = (parameters: INodeParameters, defaultCategories: string) => {
|
||||
const options = (parameters?.options ?? {}) as IDataObject;
|
||||
const categories = (options?.categories as string) ?? defaultCategories;
|
||||
const categoriesArray = categories.split(',').map((cat) => cat.trim());
|
||||
|
||||
const ret = categoriesArray.map((cat) => ({ type: 'main', displayName: cat }));
|
||||
return ret;
|
||||
};
|
||||
|
||||
export class SentimentAnalysis implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Sentiment Analysis',
|
||||
name: 'sentimentAnalysis',
|
||||
icon: 'fa:balance-scale-left',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
description: 'Analyze the sentiment of your text',
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Chains', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.sentimentanalysis/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
name: 'Sentiment Analysis',
|
||||
},
|
||||
inputs: [
|
||||
{ displayName: '', type: NodeConnectionTypes.Main },
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: `={{(${configuredOutputs})($parameter, "${DEFAULT_CATEGORIES}")}}`,
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Text to Analyze',
|
||||
name: 'inputText',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
description: 'Use an expression to reference data in previous nodes or enter static text',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Sentiment scores are LLM-generated estimates, not statistically rigorous measurements. They may be inconsistent across runs and should be used as rough indicators only.',
|
||||
name: 'detailedResultsNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.includeDetailedResults': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Sentiment Categories',
|
||||
name: 'categories',
|
||||
type: 'string',
|
||||
default: DEFAULT_CATEGORIES,
|
||||
description: 'A comma-separated list of categories to analyze',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'System Prompt Template',
|
||||
name: 'systemPromptTemplate',
|
||||
type: 'string',
|
||||
default: DEFAULT_SYSTEM_PROMPT_TEMPLATE,
|
||||
description: 'String to use directly as the system prompt template',
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Include Detailed Results',
|
||||
name: 'includeDetailedResults',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to include sentiment strength and confidence scores in the output',
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Auto-Fixing',
|
||||
name: 'enableAutoFixing',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to enable auto-fixing (may trigger an additional LLM call if output is broken)',
|
||||
},
|
||||
getBatchingOptionFields({
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
const llm = (await this.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
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.1 && batchSize > 1) {
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(async (_item, batchItemIndex) => {
|
||||
const itemIndex = i + batchItemIndex;
|
||||
const sentimentCategories = this.getNodeParameter(
|
||||
'options.categories',
|
||||
itemIndex,
|
||||
DEFAULT_CATEGORIES,
|
||||
) as string;
|
||||
|
||||
const categories = sentimentCategories
|
||||
.split(',')
|
||||
.map((cat) => cat.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return {
|
||||
result: null,
|
||||
itemIndex,
|
||||
error: new NodeOperationError(this.getNode(), 'No sentiment categories provided', {
|
||||
itemIndex,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize returnData with empty arrays for each category
|
||||
if (returnData.length === 0) {
|
||||
returnData.push(...Array.from({ length: categories.length }, () => []));
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
systemPromptTemplate?: string;
|
||||
includeDetailedResults?: boolean;
|
||||
enableAutoFixing?: boolean;
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
sentiment: z.enum(categories as [string, ...string[]]),
|
||||
strength: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe('Strength score for sentiment in relation to the category'),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
const structuredParser = StructuredOutputParser.fromZodSchema(schema);
|
||||
|
||||
const parser = options.enableAutoFixing
|
||||
? 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(
|
||||
`${escapedTemplate}
|
||||
{format_instructions}`,
|
||||
);
|
||||
|
||||
const input = this.getNodeParameter('inputText', itemIndex) as string;
|
||||
const inputPrompt = new HumanMessage(input);
|
||||
const messages = [
|
||||
await systemPromptTemplate.format({
|
||||
categories: sentimentCategories,
|
||||
format_instructions: parser.getFormatInstructions(),
|
||||
}),
|
||||
inputPrompt,
|
||||
];
|
||||
|
||||
const prompt = ChatPromptTemplate.fromMessages(messages);
|
||||
const chain = prompt.pipe(llm).pipe(parser).withConfig(getTracingConfig(this));
|
||||
|
||||
try {
|
||||
const output = await chain.invoke(messages);
|
||||
const sentimentIndex = categories.findIndex(
|
||||
(s) => s.toLowerCase() === output.sentiment.toLowerCase(),
|
||||
);
|
||||
|
||||
if (sentimentIndex !== -1) {
|
||||
const resultItem = { ...items[itemIndex] };
|
||||
const sentimentAnalysis: IDataObject = {
|
||||
category: output.sentiment,
|
||||
};
|
||||
if (options.includeDetailedResults) {
|
||||
sentimentAnalysis.strength = output.strength;
|
||||
sentimentAnalysis.confidence = output.confidence;
|
||||
}
|
||||
resultItem.json = {
|
||||
...resultItem.json,
|
||||
sentimentAnalysis,
|
||||
};
|
||||
|
||||
return {
|
||||
result: {
|
||||
resultItem,
|
||||
sentimentIndex,
|
||||
},
|
||||
itemIndex,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: {},
|
||||
itemIndex,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
result: null,
|
||||
itemIndex,
|
||||
error: new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Error during parsing of LLM output, please check your LLM model and configuration',
|
||||
{
|
||||
itemIndex,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
});
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
|
||||
batchResults.forEach(({ result, itemIndex, error }) => {
|
||||
if (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const executionErrorData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ error: error.message }),
|
||||
{ itemData: { item: itemIndex } },
|
||||
);
|
||||
|
||||
returnData[0].push(...executionErrorData);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} else if (result.resultItem && result.sentimentIndex !== -1) {
|
||||
const sentimentIndex = result.sentimentIndex;
|
||||
const resultItem = result.resultItem;
|
||||
returnData[sentimentIndex].push(resultItem);
|
||||
}
|
||||
});
|
||||
|
||||
// Add delay between batches if not the last batch
|
||||
if (i + batchSize < items.length && delayBetweenBatches > 0) {
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sequential Processing
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const sentimentCategories = this.getNodeParameter(
|
||||
'options.categories',
|
||||
i,
|
||||
DEFAULT_CATEGORIES,
|
||||
) as string;
|
||||
|
||||
const categories = sentimentCategories
|
||||
.split(',')
|
||||
.map((cat) => cat.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (categories.length === 0) {
|
||||
throw new NodeOperationError(this.getNode(), 'No sentiment categories provided', {
|
||||
itemIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize returnData with empty arrays for each category
|
||||
if (returnData.length === 0) {
|
||||
returnData.push(...Array.from({ length: categories.length }, () => []));
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', i, {}) as {
|
||||
systemPromptTemplate?: string;
|
||||
includeDetailedResults?: boolean;
|
||||
enableAutoFixing?: boolean;
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
sentiment: z.enum(categories as [string, ...string[]]),
|
||||
strength: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe('Strength score for sentiment in relation to the category'),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
const structuredParser = StructuredOutputParser.fromZodSchema(schema);
|
||||
|
||||
const parser = options.enableAutoFixing
|
||||
? 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(
|
||||
`${escapedTemplate}
|
||||
{format_instructions}`,
|
||||
);
|
||||
|
||||
const input = this.getNodeParameter('inputText', i) as string;
|
||||
const inputPrompt = new HumanMessage(input);
|
||||
const messages = [
|
||||
await systemPromptTemplate.format({
|
||||
categories: sentimentCategories,
|
||||
format_instructions: parser.getFormatInstructions(),
|
||||
}),
|
||||
inputPrompt,
|
||||
];
|
||||
|
||||
const prompt = ChatPromptTemplate.fromMessages(messages);
|
||||
const chain = prompt.pipe(llm).pipe(parser).withConfig(getTracingConfig(this));
|
||||
|
||||
try {
|
||||
const output = await chain.invoke(messages);
|
||||
const sentimentIndex = categories.findIndex(
|
||||
(s) => s.toLowerCase() === output.sentiment.toLowerCase(),
|
||||
);
|
||||
|
||||
if (sentimentIndex !== -1) {
|
||||
const resultItem = { ...items[i] };
|
||||
const sentimentAnalysis: IDataObject = {
|
||||
category: output.sentiment,
|
||||
};
|
||||
if (options.includeDetailedResults) {
|
||||
sentimentAnalysis.strength = output.strength;
|
||||
sentimentAnalysis.confidence = output.confidence;
|
||||
}
|
||||
resultItem.json = {
|
||||
...resultItem.json,
|
||||
sentimentAnalysis,
|
||||
};
|
||||
returnData[sentimentIndex].push(resultItem);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Error during parsing of LLM output, please check your LLM model and configuration',
|
||||
{
|
||||
itemIndex: i,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
const executionErrorData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray({ error: error.message }),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData[0].push(...executionErrorData);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { FakeListChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { SentimentAnalysis } from '../SentimentAnalysis.node';
|
||||
|
||||
jest.mock('@utils/tracing', () => ({
|
||||
getTracingConfig: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const createExecuteFunctionsMock = (
|
||||
parameters: any,
|
||||
fakeLlm: BaseLanguageModel,
|
||||
inputData = [{ json: { text: 'This is great!' } }],
|
||||
typeVersion = 1.1,
|
||||
) => {
|
||||
const mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
|
||||
mockExecuteFunctions.getNode.mockReturnValue({
|
||||
name: 'Sentiment Analysis',
|
||||
typeVersion,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((param, itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') {
|
||||
return parameters.inputText || inputData[itemIndex]?.json?.text || 'Test text';
|
||||
}
|
||||
if (param === 'options.categories') {
|
||||
return parameters.categories || 'Positive, Neutral, Negative';
|
||||
}
|
||||
if (param === 'options') {
|
||||
return {
|
||||
systemPromptTemplate: parameters.systemPromptTemplate,
|
||||
includeDetailedResults: parameters.includeDetailedResults || false,
|
||||
enableAutoFixing:
|
||||
parameters.enableAutoFixing !== undefined ? parameters.enableAutoFixing : true,
|
||||
};
|
||||
}
|
||||
if (param === 'options.batching.batchSize') {
|
||||
return parameters.batchSize || 5;
|
||||
}
|
||||
if (param === 'options.batching.delayBetweenBatches') {
|
||||
return parameters.delayBetweenBatches || 0;
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData.mockResolvedValue(fakeLlm);
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
constructExecutionMetaData: jest.fn().mockImplementation((data, options) => {
|
||||
return data.map((item: any) => ({
|
||||
...item,
|
||||
pairedItem: { item: options?.itemData?.item || 0 },
|
||||
}));
|
||||
}),
|
||||
returnJsonArray: jest.fn().mockImplementation((data) => [{ json: data }]),
|
||||
} as any;
|
||||
|
||||
return mockExecuteFunctions;
|
||||
};
|
||||
|
||||
describe('SentimentAnalysis Node', () => {
|
||||
let node: SentimentAnalysis;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new SentimentAnalysis();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('execute - basic functionality', () => {
|
||||
it('should analyze sentiment with default categories', async () => {
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{ inputText: 'I love this product!' },
|
||||
new FakeListChatModel({
|
||||
responses: ['{"sentiment": "Positive", "strength": 0.9, "confidence": 0.95}'],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(3); // 3 default categories
|
||||
expect(result[0]).toHaveLength(1); // Item goes to Positive category
|
||||
expect(result[0][0].json.sentimentAnalysis).toEqual({
|
||||
category: 'Positive',
|
||||
});
|
||||
});
|
||||
|
||||
it('should analyze sentiment with custom categories', async () => {
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: 'This is terrible!',
|
||||
categories: 'Happy, Sad, Angry',
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: ['{"sentiment": "Angry", "strength": 0.8, "confidence": 0.9}'],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(3); // 3 custom categories
|
||||
expect(result[2]).toHaveLength(1); // Item goes to Angry category (index 2)
|
||||
expect(result[2][0].json.sentimentAnalysis).toEqual({
|
||||
category: 'Angry',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include detailed results when enabled', async () => {
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: 'I love this!',
|
||||
includeDetailedResults: true,
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: ['{"sentiment": "Positive", "strength": 0.9, "confidence": 0.95}'],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0][0].json.sentimentAnalysis).toEqual({
|
||||
category: 'Positive',
|
||||
strength: 0.9,
|
||||
confidence: 0.95,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const inputData = [
|
||||
{ json: { text: 'I love this!' } },
|
||||
{ json: { text: 'This is okay.' } },
|
||||
{ json: { text: 'I hate this!' } },
|
||||
];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{ inputText: '{{$json.text}}' },
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
'{"sentiment": "Positive", "strength": 0.9, "confidence": 0.95}',
|
||||
'{"sentiment": "Neutral", "strength": 0.5, "confidence": 0.8}',
|
||||
'{"sentiment": "Negative", "strength": 0.9, "confidence": 0.9}',
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0]).toHaveLength(1); // Positive
|
||||
expect(result[1]).toHaveLength(1); // Neutral
|
||||
expect(result[2]).toHaveLength(1); // Negative
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - error handling', () => {
|
||||
it('should throw error when no categories provided', async () => {
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: 'Test text',
|
||||
categories: '',
|
||||
},
|
||||
new FakeListChatModel({ responses: ['test'] }),
|
||||
);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle parsing errors with auto-fixing disabled', async () => {
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: 'Test text',
|
||||
enableAutoFixing: false,
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: ['Invalid JSON response'],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunctions)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should continue on failure when configured', async () => {
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: 'Test text',
|
||||
enableAutoFixing: false,
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: ['Invalid JSON response'],
|
||||
}),
|
||||
);
|
||||
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - batching (version 1.1+)', () => {
|
||||
it('should process items in batches with default settings', async () => {
|
||||
const inputData = [
|
||||
{ json: { text: 'Great!' } },
|
||||
{ json: { text: 'Okay.' } },
|
||||
{ json: { text: 'Bad!' } },
|
||||
];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: '{{$json.text}}',
|
||||
batchSize: 2,
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
'{"sentiment": "Positive", "strength": 0.9, "confidence": 0.95}',
|
||||
'{"sentiment": "Neutral", "strength": 0.5, "confidence": 0.8}',
|
||||
'{"sentiment": "Negative", "strength": 0.9, "confidence": 0.9}',
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0]).toHaveLength(1); // Positive
|
||||
expect(result[1]).toHaveLength(1); // Neutral
|
||||
expect(result[2]).toHaveLength(1); // Negative
|
||||
});
|
||||
|
||||
it('should handle errors in batch processing with continueOnFail', async () => {
|
||||
const inputData = [
|
||||
{ json: { text: 'Great!' } },
|
||||
{ json: { text: 'Invalid text' } },
|
||||
{ json: { text: 'Bad!' } },
|
||||
];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: '{{$json.text}}',
|
||||
batchSize: 2,
|
||||
enableAutoFixing: false,
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
'{"sentiment": "Positive", "strength": 0.9, "confidence": 0.95}',
|
||||
'Invalid JSON',
|
||||
'{"sentiment": "Negative", "strength": 0.9, "confidence": 0.9}',
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
);
|
||||
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0]).toHaveLength(2); // Positive and error item
|
||||
expect((result[0][0].json.sentimentAnalysis as any).category).toBe('Positive');
|
||||
expect(result[0][1].json).toHaveProperty('error');
|
||||
expect(result[2]).toHaveLength(1); // Negative
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - sequential processing (version 1.0)', () => {
|
||||
it('should process items sequentially in older versions', async () => {
|
||||
const inputData = [{ json: { text: 'Great!' } }, { json: { text: 'Okay.' } }];
|
||||
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{ inputText: '{{$json.text}}' },
|
||||
new FakeListChatModel({
|
||||
responses: [
|
||||
'{"sentiment": "Positive", "strength": 0.9, "confidence": 0.95}',
|
||||
'{"sentiment": "Neutral", "strength": 0.5, "confidence": 0.8}',
|
||||
],
|
||||
}),
|
||||
inputData,
|
||||
1.0, // Older version
|
||||
);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0]).toHaveLength(1); // Positive
|
||||
expect(result[1]).toHaveLength(1); // Neutral
|
||||
});
|
||||
|
||||
it('should handle errors in sequential processing', async () => {
|
||||
const mockExecuteFunctions = createExecuteFunctionsMock(
|
||||
{
|
||||
inputText: 'Test text',
|
||||
enableAutoFixing: false,
|
||||
},
|
||||
new FakeListChatModel({
|
||||
responses: ['Invalid JSON'],
|
||||
}),
|
||||
undefined,
|
||||
1.0,
|
||||
);
|
||||
|
||||
mockExecuteFunctions.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { OutputFixingParser, StructuredOutputParser } from '@langchain/classic/output_parsers';
|
||||
import { NodeOperationError, NodeConnectionTypes, sleep } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getBatchingOptionFields } from '@n8n/ai-utilities';
|
||||
|
||||
import { processItem } from './processItem';
|
||||
|
||||
const SYSTEM_PROMPT_TEMPLATE =
|
||||
"Please classify the text provided by the user into one of the following categories: {categories}, and use the provided formatting instructions below. Don't explain, and only output the json.";
|
||||
|
||||
const configuredOutputs = (parameters: INodeParameters) => {
|
||||
const categories = ((parameters.categories as IDataObject)?.categories as IDataObject[]) ?? [];
|
||||
const fallback = (parameters.options as IDataObject)?.fallback as string;
|
||||
const ret = categories.map((cat) => {
|
||||
return { type: 'main', displayName: cat.category };
|
||||
});
|
||||
if (fallback === 'other') ret.push({ type: 'main', displayName: 'Other' });
|
||||
return ret;
|
||||
};
|
||||
|
||||
export class TextClassifier implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Text Classifier',
|
||||
name: 'textClassifier',
|
||||
icon: 'fa:tags',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
description: 'Classify your text into distinct categories',
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Chains', 'Root Nodes'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.text-classifier/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
name: 'Text Classifier',
|
||||
},
|
||||
inputs: [
|
||||
{ displayName: '', type: NodeConnectionTypes.Main },
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: `={{(${configuredOutputs})($parameter)}}`,
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
},
|
||||
message:
|
||||
'Each category defined creates a separate output branch. Output 0 corresponds to the first category, output 1 to the second, and so on. Use .output(index).to() to connect from a specific category. @example textClassifier.output(0).to(nodeA) and textClassifier.output(1).to(nodeB)',
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Text to Classify',
|
||||
name: 'inputText',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
description: 'Use an expression to reference data in previous nodes or enter static text',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Categories',
|
||||
name: 'categories',
|
||||
placeholder: 'Add Category',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'categories',
|
||||
displayName: 'Categories',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Category',
|
||||
name: 'category',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Category to add',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: "Describe your category if it's not obvious",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
placeholder: 'Add Option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Allow Multiple Classes To Be True',
|
||||
name: 'multiClass',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'When No Clear Match',
|
||||
name: 'fallback',
|
||||
type: 'options',
|
||||
default: 'discard',
|
||||
description: 'What to do with items that don’t match the categories exactly',
|
||||
options: [
|
||||
{
|
||||
name: 'Discard Item',
|
||||
value: 'discard',
|
||||
description: 'Ignore the item and drop it from the output',
|
||||
},
|
||||
{
|
||||
name: "Output on Extra, 'Other' Branch",
|
||||
value: 'other',
|
||||
description: "Create a separate output branch called 'Other'",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'System Prompt Template',
|
||||
name: 'systemPromptTemplate',
|
||||
type: 'string',
|
||||
default: SYSTEM_PROMPT_TEMPLATE,
|
||||
description: 'String to use directly as the system prompt template',
|
||||
typeOptions: {
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enable Auto-Fixing',
|
||||
name: 'enableAutoFixing',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to enable auto-fixing (may trigger an additional LLM call if output is broken)',
|
||||
},
|
||||
getBatchingOptionFields({
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const batchSize = this.getNodeParameter('options.batching.batchSize', 0, 5) as number;
|
||||
const delayBetweenBatches = this.getNodeParameter(
|
||||
'options.batching.delayBetweenBatches',
|
||||
0,
|
||||
0,
|
||||
) as number;
|
||||
|
||||
const llm = (await this.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
0,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
const categories = this.getNodeParameter('categories.categories', 0, []) as Array<{
|
||||
category: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
if (categories.length === 0) {
|
||||
throw new NodeOperationError(this.getNode(), 'At least one category must be defined');
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {}) as {
|
||||
multiClass: boolean;
|
||||
fallback?: string;
|
||||
systemPromptTemplate?: string;
|
||||
enableAutoFixing: boolean;
|
||||
};
|
||||
const multiClass = options?.multiClass ?? false;
|
||||
const fallback = options?.fallback ?? 'discard';
|
||||
|
||||
const schemaEntries = categories.map((cat) => [
|
||||
cat.category,
|
||||
z
|
||||
.boolean()
|
||||
.describe(
|
||||
`Should be true if the input has category "${cat.category}" (description: ${cat.description})`,
|
||||
),
|
||||
]);
|
||||
if (fallback === 'other')
|
||||
schemaEntries.push([
|
||||
'fallback',
|
||||
z.boolean().describe('Should be true if none of the other categories apply'),
|
||||
]);
|
||||
const schema = z.object(Object.fromEntries(schemaEntries));
|
||||
|
||||
const structuredParser = StructuredOutputParser.fromZodSchema(schema);
|
||||
|
||||
const parser = options.enableAutoFixing
|
||||
? OutputFixingParser.fromLLM(llm, structuredParser)
|
||||
: structuredParser;
|
||||
|
||||
const multiClassPrompt = multiClass
|
||||
? 'Categories are not mutually exclusive, and multiple can be true'
|
||||
: 'Categories are mutually exclusive, and only one can be true';
|
||||
|
||||
const fallbackPrompt = {
|
||||
other: 'If no categories apply, select the "fallback" option.',
|
||||
discard: 'If there is not a very fitting category, select none of the categories.',
|
||||
}[fallback];
|
||||
|
||||
const returnData: INodeExecutionData[][] = Array.from(
|
||||
{ length: categories.length + (fallback === 'other' ? 1 : 0) },
|
||||
(_) => [],
|
||||
);
|
||||
|
||||
if (this.getNode().typeVersion >= 1.1 && batchSize > 1) {
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(async (_item, batchItemIndex) => {
|
||||
const itemIndex = i + batchItemIndex;
|
||||
const item = items[itemIndex];
|
||||
|
||||
return await processItem(
|
||||
this,
|
||||
itemIndex,
|
||||
item,
|
||||
llm,
|
||||
parser,
|
||||
categories,
|
||||
multiClassPrompt,
|
||||
fallbackPrompt,
|
||||
);
|
||||
});
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
batchResults.forEach((response, batchItemIndex) => {
|
||||
const index = i + batchItemIndex;
|
||||
if (response.status === 'rejected') {
|
||||
const error = response.reason as Error;
|
||||
if (this.continueOnFail()) {
|
||||
returnData[0].push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: index },
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
throw new NodeOperationError(this.getNode(), error.message);
|
||||
}
|
||||
} else {
|
||||
const output = response.value;
|
||||
const item = items[index];
|
||||
|
||||
categories.forEach((cat, idx) => {
|
||||
if (output[cat.category]) returnData[idx].push(item);
|
||||
});
|
||||
|
||||
if (fallback === 'other' && output.fallback)
|
||||
returnData[returnData.length - 1].push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Add delay between batches if not the last batch
|
||||
if (i + batchSize < items.length && delayBetweenBatches > 0) {
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
const item = items[itemIndex];
|
||||
|
||||
try {
|
||||
const output = await processItem(
|
||||
this,
|
||||
itemIndex,
|
||||
item,
|
||||
llm,
|
||||
parser,
|
||||
categories,
|
||||
multiClassPrompt,
|
||||
fallbackPrompt,
|
||||
);
|
||||
|
||||
categories.forEach((cat, idx) => {
|
||||
if (output[cat.category]) returnData[idx].push(item);
|
||||
});
|
||||
if (fallback === 'other' && output.fallback) returnData[returnData.length - 1].push(item);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData[0].push({
|
||||
json: { error: error.message },
|
||||
pairedItem: { item: itemIndex },
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SYSTEM_PROMPT_TEMPLATE =
|
||||
"Please classify the text provided by the user into one of the following categories: {categories}, and use the provided formatting instructions below. Don't explain, and only output the json.";
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts';
|
||||
import type { OutputFixingParser, StructuredOutputParser } from '@langchain/classic/output_parsers';
|
||||
import { NodeOperationError, type IExecuteFunctions, type INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { getTracingConfig } from '@utils/tracing';
|
||||
|
||||
import { SYSTEM_PROMPT_TEMPLATE } from './constants';
|
||||
|
||||
export async function processItem(
|
||||
ctx: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
item: INodeExecutionData,
|
||||
llm: BaseLanguageModel,
|
||||
parser: StructuredOutputParser<any> | OutputFixingParser<any>,
|
||||
categories: Array<{ category: string; description: string }>,
|
||||
multiClassPrompt: string,
|
||||
fallbackPrompt: string | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const input = ctx.getNodeParameter('inputText', itemIndex) as string;
|
||||
|
||||
if (!input) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Text to classify for item ${itemIndex} is not defined`,
|
||||
);
|
||||
}
|
||||
|
||||
item.pairedItem = { item: itemIndex };
|
||||
|
||||
const inputPrompt = new HumanMessage(input);
|
||||
|
||||
const systemPromptTemplateOpt = ctx.getNodeParameter(
|
||||
'options.systemPromptTemplate',
|
||||
itemIndex,
|
||||
SYSTEM_PROMPT_TEMPLATE,
|
||||
) as string;
|
||||
const escapedTemplate = (systemPromptTemplateOpt ?? SYSTEM_PROMPT_TEMPLATE)
|
||||
.replace(/[{}]/g, (match) => match + match)
|
||||
.replaceAll('{{categories}}', '{categories}');
|
||||
|
||||
const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate(
|
||||
`${escapedTemplate}
|
||||
{format_instructions}
|
||||
${multiClassPrompt}
|
||||
${fallbackPrompt}`,
|
||||
);
|
||||
|
||||
const messages = [
|
||||
await systemPromptTemplate.format({
|
||||
categories: categories.map((cat) => cat.category).join(', '),
|
||||
format_instructions: parser.getFormatInstructions(),
|
||||
}),
|
||||
inputPrompt,
|
||||
];
|
||||
const prompt = ChatPromptTemplate.fromMessages(messages);
|
||||
const chain = prompt.pipe(llm).pipe(parser).withConfig(getTracingConfig(ctx));
|
||||
|
||||
return await chain.invoke(messages);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { FakeChatModel } from '@langchain/core/utils/testing';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import { processItem } from '../processItem';
|
||||
import { TextClassifier } from '../TextClassifier.node';
|
||||
|
||||
jest.mock('../processItem', () => ({
|
||||
processItem: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('TextClassifier Node', () => {
|
||||
let node: TextClassifier;
|
||||
let mockExecuteFunction: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
node = new TextClassifier();
|
||||
mockExecuteFunction = mock<IExecuteFunctions>();
|
||||
|
||||
mockExecuteFunction.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([{ json: { testValue: 'none' } }]);
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Text Classifier',
|
||||
typeVersion: 1.1,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') return 'Test input';
|
||||
if (param === 'categories.categories')
|
||||
return [{ category: 'test', description: 'test category' }];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const fakeLLM = new FakeChatModel({});
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue(fakeLLM);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should process items with correct parameters', async () => {
|
||||
(processItem as jest.Mock).mockResolvedValue({ test: true });
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(processItem).toHaveBeenCalledWith(
|
||||
mockExecuteFunction,
|
||||
0,
|
||||
{ json: { testValue: 'none' } },
|
||||
expect.any(FakeChatModel),
|
||||
expect.any(Object),
|
||||
[{ category: 'test', description: 'test category' }],
|
||||
expect.any(String),
|
||||
'If there is not a very fitting category, select none of the categories.',
|
||||
);
|
||||
|
||||
expect(result).toEqual([[{ json: { testValue: 'none' } }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') return 'Test input';
|
||||
if (param === 'categories.categories')
|
||||
return [
|
||||
{ category: 'test1', description: 'test category' },
|
||||
{ category: 'test2', description: 'some other category' },
|
||||
];
|
||||
return defaultValue;
|
||||
});
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
]);
|
||||
|
||||
(processItem as jest.Mock)
|
||||
.mockResolvedValueOnce({ test1: true, test2: false })
|
||||
.mockResolvedValueOnce({ test1: false, test2: true });
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(processItem).toHaveBeenCalledTimes(2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0][0].json).toEqual({ item: 1 });
|
||||
expect(result[1][0].json).toEqual({ item: 2 });
|
||||
});
|
||||
|
||||
it('should process items in batches when batchSize is set', async () => {
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') return 'Test input';
|
||||
if (param === 'categories.categories')
|
||||
return [{ category: 'test', description: 'test category' }];
|
||||
if (param === 'batchSize') return 2;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
|
||||
(processItem as jest.Mock)
|
||||
.mockResolvedValueOnce({ test: true })
|
||||
.mockResolvedValueOnce({ test: true })
|
||||
.mockResolvedValueOnce({ test: true })
|
||||
.mockResolvedValueOnce({ test: true });
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(processItem).toHaveBeenCalledTimes(4);
|
||||
expect(result[0]).toHaveLength(4);
|
||||
expect(result[0]).toEqual([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should respect delayBetweenBatches', async () => {
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') return 'Test input';
|
||||
if (param === 'categories.categories')
|
||||
return [{ category: 'test', description: 'test category' }];
|
||||
if (param === 'options.batching.batchSize') return 2;
|
||||
if (param === 'options.batching.delayBetweenBatches') return 100;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
{ json: { item: 4 } },
|
||||
{ json: { item: 5 } },
|
||||
{ json: { item: 6 } },
|
||||
]);
|
||||
|
||||
(processItem as jest.Mock).mockResolvedValue({ test: true });
|
||||
|
||||
const startTime = Date.now();
|
||||
await node.execute.call(mockExecuteFunction);
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('should handle errors in batch processing', async () => {
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') return 'Test input';
|
||||
if (param === 'categories.categories')
|
||||
return [{ category: 'test', description: 'test category' }];
|
||||
if (param === 'batchSize') return 2;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
mockExecuteFunction.getInputData.mockReturnValue([
|
||||
{ json: { item: 1 } },
|
||||
{ json: { item: 2 } },
|
||||
{ json: { item: 3 } },
|
||||
]);
|
||||
|
||||
(processItem as jest.Mock)
|
||||
.mockResolvedValueOnce({ test: true })
|
||||
.mockRejectedValueOnce(new Error('Batch error'))
|
||||
.mockResolvedValueOnce({ test: true });
|
||||
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result[0]).toHaveLength(3);
|
||||
expect(result[0][1].json).toHaveProperty('error', 'Batch error');
|
||||
});
|
||||
|
||||
it('should throw error when continueOnFail is false', async () => {
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(false);
|
||||
(processItem as jest.Mock).mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('should continue on failure when configured', async () => {
|
||||
mockExecuteFunction.continueOnFail.mockReturnValue(true);
|
||||
(processItem as jest.Mock).mockRejectedValue(new Error('Test error'));
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: { error: 'Test error' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as tracing from '@utils/tracing';
|
||||
|
||||
import { processItem } from '../processItem';
|
||||
|
||||
jest.mock('@utils/tracing', () => ({
|
||||
getTracingConfig: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('@langchain/core/prompts', () => ({
|
||||
ChatPromptTemplate: {
|
||||
fromMessages: jest.fn(),
|
||||
},
|
||||
SystemMessagePromptTemplate: {
|
||||
fromTemplate: jest.fn().mockReturnValue({
|
||||
format: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('processItem', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
let fakeLLM: FakeChatModel;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
fakeLLM = new FakeChatModel({});
|
||||
|
||||
mockContext.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') return 'Test input';
|
||||
if (param === 'options.systemPromptTemplate') return 'Test system prompt';
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should throw error for empty input text', async () => {
|
||||
mockContext.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => {
|
||||
if (param === 'inputText') return '';
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
await expect(
|
||||
processItem(
|
||||
mockContext,
|
||||
0,
|
||||
{ json: {} },
|
||||
fakeLLM,
|
||||
{ getFormatInstructions: () => 'format instructions' } as any,
|
||||
[{ category: 'test', description: 'test category' }],
|
||||
'multi class prompt',
|
||||
undefined,
|
||||
),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should process item with correct parameters', async () => {
|
||||
const mockParser = {
|
||||
getFormatInstructions: () => '[format instructions]',
|
||||
};
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ test: true }),
|
||||
};
|
||||
|
||||
const mockPipe = jest.fn().mockReturnValue({
|
||||
pipe: jest.fn().mockReturnValue({
|
||||
withConfig: jest.fn().mockReturnValue(mockChain),
|
||||
}),
|
||||
});
|
||||
|
||||
const mockPrompt = {
|
||||
pipe: mockPipe,
|
||||
};
|
||||
|
||||
jest.mocked(ChatPromptTemplate.fromMessages).mockReturnValue(mockPrompt as any);
|
||||
|
||||
const result = await processItem(
|
||||
mockContext,
|
||||
0,
|
||||
{ json: {} },
|
||||
fakeLLM,
|
||||
mockParser as any,
|
||||
[{ category: 'test', description: 'test category' }],
|
||||
'multi class prompt',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ test: true });
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('inputText', 0);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith(
|
||||
'options.systemPromptTemplate',
|
||||
0,
|
||||
expect.any(String),
|
||||
);
|
||||
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]',
|
||||
};
|
||||
|
||||
const mockChain = {
|
||||
invoke: jest.fn().mockResolvedValue({ category: 'test' }),
|
||||
};
|
||||
|
||||
const mockPipe = jest.fn().mockReturnValue({
|
||||
pipe: jest.fn().mockReturnValue({
|
||||
withConfig: jest.fn().mockReturnValue(mockChain),
|
||||
}),
|
||||
});
|
||||
|
||||
const mockPrompt = {
|
||||
pipe: mockPipe,
|
||||
};
|
||||
|
||||
jest.mocked(ChatPromptTemplate.fromMessages).mockReturnValue(mockPrompt as any);
|
||||
|
||||
await processItem(
|
||||
mockContext,
|
||||
0,
|
||||
{ json: {} },
|
||||
fakeLLM,
|
||||
mockParser as any,
|
||||
[{ category: 'test', description: 'test category' }],
|
||||
'multi class prompt',
|
||||
'fallback prompt',
|
||||
);
|
||||
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith(
|
||||
'options.systemPromptTemplate',
|
||||
0,
|
||||
expect.any(String),
|
||||
);
|
||||
expect(tracing.getTracingConfig).toHaveBeenCalledWith(mockContext);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user