feat(n8n): add interactive deploy and fix nodes

This commit is contained in:
Gabriel Ramos 2026-06-09 10:37:31 -04:00
parent b751ee556f
commit fbb23a1470

View file

@ -1,5 +1,6 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as readline from "readline";
// Load .env variables // Load .env variables
const envPath = path.join(__dirname, "../.env"); const envPath = path.join(__dirname, "../.env");
@ -19,7 +20,6 @@ if (fs.existsSync(envPath)) {
const N8N_HOST = process.env.N8N_HOST || "https://n8n.gaboggamer.online"; const N8N_HOST = process.env.N8N_HOST || "https://n8n.gaboggamer.online";
const N8N_API_KEY = process.env.N8N_API_KEY; const N8N_API_KEY = process.env.N8N_API_KEY;
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SUPABASE_SECRET_KEY = process.env.SUPABASE_SECRET_KEY; const SUPABASE_SECRET_KEY = process.env.SUPABASE_SECRET_KEY;
@ -28,6 +28,21 @@ if (!N8N_API_KEY) {
process.exit(1); process.exit(1);
} }
// Interactive prompt helper
function askQuestion(query: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) =>
rl.question(query, (ans) => {
rl.close();
resolve(ans.trim());
})
);
}
async function n8nRequest(endpoint: string, method: string = "GET", body?: any) { async function n8nRequest(endpoint: string, method: string = "GET", body?: any) {
const response = await fetch(`${N8N_HOST}${endpoint}`, { const response = await fetch(`${N8N_HOST}${endpoint}`, {
method, method,
@ -68,31 +83,165 @@ async function getOrCreateCredential(name: string, type: string, data: any) {
} }
} }
async function main() { interface ProviderConfig {
console.log("Starting n8n Candidate Evaluation Flow deployment..."); nodeType: string;
credentialType: string;
credentialData: any;
nodeParameters: any;
}
// 1. Create/Retrieve Supabase credential function getProviderConfig(provider: string, apiKey: string, modelName: string): ProviderConfig {
console.log("Checking Supabase credentials..."); switch (provider) {
const supabaseCredId = await getOrCreateCredential("Semillero2_Supabase_V2", "supabaseApi", { case "1": // Deepseek (Native)
return {
nodeType: "@n8n/n8n-nodes-langchain.lmChatDeepSeek",
credentialType: "deepSeekApi",
credentialData: {
apiKey,
allowedHttpRequestDomains: "none",
},
nodeParameters: {
model: modelName || "deepseek-chat",
options: {},
},
};
case "2": // OpenAI
return {
nodeType: "@n8n/n8n-nodes-langchain.lmChatOpenAi",
credentialType: "openAiApi",
credentialData: {
apiKey,
header: false,
allowedHttpRequestDomains: "none",
},
nodeParameters: {
model: modelName || "gpt-4o-mini",
options: {},
},
};
case "3": // Google Gemini
return {
nodeType: "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
credentialType: "googlePalmApi",
credentialData: {
apiKey,
host: "https://generativelanguage.googleapis.com",
allowedHttpRequestDomains: "none",
},
nodeParameters: {
model: modelName || "gemini-1.5-flash",
options: {},
},
};
case "4": // Anthropic
return {
nodeType: "@n8n/n8n-nodes-langchain.lmChatAnthropic",
credentialType: "anthropicApi",
credentialData: {
apiKey,
allowedHttpRequestDomains: "none",
},
nodeParameters: {
model: modelName || "claude-3-5-sonnet-latest",
options: {},
},
};
default:
throw new Error("Invalid provider chosen");
}
}
async function main() {
console.log("\n==================================================");
console.log("Welcome to interactive n8n workflow deployment");
console.log("==================================================");
// 1. Ask for Primary Provider
console.log("\nSelect Primary LLM Provider:");
console.log("1. Deepseek (Native Node)");
console.log("2. OpenAI (Standard)");
console.log("3. Google Gemini");
console.log("4. Anthropic");
const primaryProviderChoice = (await askQuestion("Enter choice (1-4) [default: 3]: ")) || "3";
let defaultModel = "gemini-1.5-flash";
if (primaryProviderChoice === "1") defaultModel = "deepseek-chat";
else if (primaryProviderChoice === "2") defaultModel = "gpt-4o-mini";
else if (primaryProviderChoice === "4") defaultModel = "claude-3-5-sonnet-latest";
const primaryModelName = (await askQuestion(`Enter primary model name [default: ${defaultModel}]: `)) || defaultModel;
let defaultKey = "";
if (primaryProviderChoice === "1") defaultKey = process.env.DEEPSEEK_API_KEY || "";
else if (primaryProviderChoice === "3") defaultKey = process.env.GEMINI_API_KEY || "";
const primaryApiKey = (await askQuestion(`Enter API key [default: ${defaultKey ? "Loaded from .env" : "None"}]: `)) || defaultKey;
if (!primaryApiKey) {
console.error("Primary API Key is required.");
process.exit(1);
}
// 2. Ask for Fallback Provider
const configureFallback = ((await askQuestion("\nDo you want to configure a Fallback LLM Model? (y/n) [default: n]: ")) || "n").toLowerCase() === "y";
let fallbackProviderChoice = "";
let fallbackModelName = "";
let fallbackApiKey = "";
if (configureFallback) {
console.log("\nSelect Fallback LLM Provider:");
console.log("1. Deepseek (Native Node)");
console.log("2. OpenAI (Standard)");
console.log("3. Google Gemini");
console.log("4. Anthropic");
fallbackProviderChoice = (await askQuestion("Enter choice (1-4) [default: 1]: ")) || "1";
let defaultFallbackModel = "deepseek-chat";
if (fallbackProviderChoice === "2") defaultFallbackModel = "gpt-4o-mini";
else if (fallbackProviderChoice === "3") defaultFallbackModel = "gemini-1.5-flash";
else if (fallbackProviderChoice === "4") defaultFallbackModel = "claude-3-5-sonnet-latest";
fallbackModelName = (await askQuestion(`Enter fallback model name [default: ${defaultFallbackModel}]: `)) || defaultFallbackModel;
let defaultFallbackKey = "";
if (fallbackProviderChoice === "1") defaultFallbackKey = process.env.DEEPSEEK_API_KEY || "";
else if (fallbackProviderChoice === "3") defaultFallbackKey = process.env.GEMINI_API_KEY || "";
fallbackApiKey = (await askQuestion(`Enter fallback API key [default: ${defaultFallbackKey ? "Loaded from .env" : "None"}]: `)) || defaultFallbackKey;
if (!fallbackApiKey) {
console.error("Fallback API Key is required.");
process.exit(1);
}
}
console.log("\nDeploying credentials to n8n...");
const supabaseCredId = await getOrCreateCredential("Semillero2_Supabase_V3", "supabaseApi", {
host: SUPABASE_URL, host: SUPABASE_URL,
serviceRole: SUPABASE_SECRET_KEY, serviceRole: SUPABASE_SECRET_KEY,
allowedHttpRequestDomains: "none", allowedHttpRequestDomains: "none",
}); });
// 2. Create/Retrieve Gemini credential const primaryConfig = getProviderConfig(primaryProviderChoice, primaryApiKey, primaryModelName);
console.log("Checking Gemini credentials..."); const primaryCredId = await getOrCreateCredential(
const geminiCredId = await getOrCreateCredential("Semillero2_Gemini_V2", "googlePalmApi", { `Semillero2_Primary_${primaryConfig.credentialType}`,
apiKey: GEMINI_API_KEY, primaryConfig.credentialType,
host: "https://generativelanguage.googleapis.com", primaryConfig.credentialData
allowedHttpRequestDomains: "none", );
});
// 3. Define the E2E Candidate Evaluation workflow let fallbackConfig: ProviderConfig | null = null;
const workflowDefinition = { let fallbackCredId = "";
name: "Semillero2: End-to-End Candidate Evaluation", if (configureFallback) {
settings: {}, fallbackConfig = getProviderConfig(fallbackProviderChoice, fallbackApiKey, fallbackModelName);
nodes: [ fallbackCredId = await getOrCreateCredential(
{ `Semillero2_Fallback_${fallbackConfig.credentialType}`,
fallbackConfig.credentialType,
fallbackConfig.credentialData
);
}
// 3. Define workflow nodes dynamically
console.log("\nBuilding workflow nodes...");
const webhookTriggerNode = {
parameters: { parameters: {
httpMethod: "POST", httpMethod: "POST",
path: "evaluate-candidate", path: "evaluate-candidate",
@ -104,37 +253,39 @@ async function main() {
type: "n8n-nodes-base.webhook", type: "n8n-nodes-base.webhook",
typeVersion: 1.1, typeVersion: 1.1,
position: [100, 300], position: [100, 300],
}, };
{
const primaryChainNode = {
parameters: { parameters: {
promptType: "Define below", promptType: "defineBelow", // Fixed casing: camelCase!
text: "={{ $json.body.text }}", hasOutputParser: true, // Enforce "Require Specific Output Format"
text: "={{ $('Webhook Trigger').item.json.body.text }}",
systemMessage: "You are an AI recruitment assistant evaluating a candidate's CV for a job vacancy. Analyze the candidate's CV text. You MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief candidate summary (max 3 sentences).\n- classification: 'Qualified', 'Unqualified', or 'Review'.\n- suggestions: an array of recommendations for next steps (e.g. ['Schedule interview', 'Reject', 'Verify references']).\n- riskLevel: 'Low', 'Medium', or 'High'.\n- ai_score: a number between 0 and 100 representing general suitability.\n\nDo not include markdown code blocks or any text outside the JSON.", systemMessage: "You are an AI recruitment assistant evaluating a candidate's CV for a job vacancy. Analyze the candidate's CV text. You MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief candidate summary (max 3 sentences).\n- classification: 'Qualified', 'Unqualified', or 'Review'.\n- suggestions: an array of recommendations for next steps (e.g. ['Schedule interview', 'Reject', 'Verify references']).\n- riskLevel: 'Low', 'Medium', or 'High'.\n- ai_score: a number between 0 and 100 representing general suitability.\n\nDo not include markdown code blocks or any text outside the JSON.",
}, },
id: "llm-chain", id: "llm-chain-primary",
name: "LLM Chain Evaluation", name: "LLM Chain Evaluation (Primary)",
type: "@n8n/n8n-nodes-langchain.chainLlm", type: "@n8n/n8n-nodes-langchain.chainLlm",
typeVersion: 1.4, typeVersion: 1.4,
position: [350, 300], position: [400, 300],
}, continueOnFail: configureFallback, // continue on fail only if fallback exists
{ };
parameters: {
model: "gemini-1.5-flash", const primaryModelNode = {
options: {}, parameters: primaryConfig.nodeParameters,
}, id: "primary-model",
id: "gemini-model", name: "Primary Chat Model",
name: "Gemini Chat Model", type: primaryConfig.nodeType,
type: "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
typeVersion: 1, typeVersion: 1,
position: [300, 480], position: [350, 480],
credentials: { credentials: {
googlePalmApi: { [primaryConfig.credentialType]: {
id: geminiCredId, id: primaryCredId,
name: "Semillero2_Gemini_V2", name: `Semillero2_Primary_${primaryConfig.credentialType}`,
}, },
}, },
}, };
{
const jsonParserNode = {
parameters: { parameters: {
jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"summary\": {\n \"type\": \"string\"\n },\n \"classification\": {\n \"type\": \"string\",\n \"enum\": [\"Qualified\", \"Unqualified\", \"Review\"]\n },\n \"suggestions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"riskLevel\": {\n \"type\": \"string\",\n \"enum\": [\"Low\", \"Medium\", \"High\"]\n },\n \"ai_score\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\"summary\", \"classification\", \"suggestions\", \"riskLevel\", \"ai_score\"]\n}", jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"summary\": {\n \"type\": \"string\"\n },\n \"classification\": {\n \"type\": \"string\",\n \"enum\": [\"Qualified\", \"Unqualified\", \"Review\"]\n },\n \"suggestions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"riskLevel\": {\n \"type\": \"string\",\n \"enum\": [\"Low\", \"Medium\", \"High\"]\n },\n \"ai_score\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\"summary\", \"classification\", \"suggestions\", \"riskLevel\", \"ai_score\"]\n}",
}, },
@ -142,33 +293,48 @@ async function main() {
name: "Structured Output Parser", name: "Structured Output Parser",
type: "@n8n/n8n-nodes-langchain.outputParserStructured", type: "@n8n/n8n-nodes-langchain.outputParserStructured",
typeVersion: 1, typeVersion: 1,
position: [460, 480], position: [480, 480],
};
// Replace Code node with a native Edit Fields (Set) node to avoid code blocks
const setNode = {
parameters: {
assignments: {
assignments: [
{
name: "candidate_id",
value: "={{ $('Webhook Trigger').item.json.body.candidateId }}",
type: "string",
}, },
{ {
parameters: { name: "interview_id",
jsCode: `const input = $input.first().json; value: "={{ $('Webhook Trigger').item.json.body.interviewId }}",
const webhookData = $('Webhook Trigger').first().json.body; type: "string",
return [{ },
json: { {
candidate_id: webhookData.candidateId, name: "ai_score",
interview_id: webhookData.interviewId, value: "={{ $json.ai_score }}",
ai_score: input.ai_score, type: "number",
evaluation: { },
summary: input.summary, {
classification: input.classification, name: "evaluation",
suggestions: input.suggestions, value: "={{ { summary: $json.summary, classification: $json.classification, suggestions: $json.suggestions, riskLevel: $json.riskLevel } }}",
riskLevel: input.riskLevel type: "object",
} },
} ],
}];`, },
options: {
includeOtherFields: false, // Drop other fields to cleanly match schema
},
}, },
id: "format-data", id: "format-data",
name: "Format Evaluation Data", name: "Format Evaluation Data",
type: "n8n-nodes-base.code", type: "n8n-nodes-base.set",
typeVersion: 2, typeVersion: 3,
position: [600, 300], position: [850, 300],
}, };
{
const supabaseInsertNode = {
parameters: { parameters: {
operation: "insert", operation: "insert",
table: "scores", table: "scores",
@ -178,15 +344,16 @@ return [{
name: "Insert Score to Supabase", name: "Insert Score to Supabase",
type: "n8n-nodes-base.supabase", type: "n8n-nodes-base.supabase",
typeVersion: 1, typeVersion: 1,
position: [800, 300], position: [1050, 300],
credentials: { credentials: {
supabaseApi: { supabaseApi: {
id: supabaseCredId, id: supabaseCredId,
name: "Semillero2_Supabase_V2", name: "Semillero2_Supabase_V3",
}, },
}, },
}, };
{
const respondWebhookNode = {
parameters: { parameters: {
options: {}, options: {},
}, },
@ -194,26 +361,36 @@ return [{
name: "Respond to Webhook", name: "Respond to Webhook",
type: "n8n-nodes-base.respondToWebhook", type: "n8n-nodes-base.respondToWebhook",
typeVersion: 1.1, typeVersion: 1.1,
position: [1000, 300], position: [1250, 300],
}, };
],
connections: { const wNodes: any[] = [
webhookTriggerNode,
primaryChainNode,
primaryModelNode,
jsonParserNode,
setNode,
supabaseInsertNode,
respondWebhookNode,
];
const wConnections: any = {
"Webhook Trigger": { "Webhook Trigger": {
main: [ main: [
[ [
{ {
node: "LLM Chain Evaluation", node: "LLM Chain Evaluation (Primary)",
type: "main", type: "main",
index: 0, index: 0,
}, },
], ],
], ],
}, },
"Gemini Chat Model": { "Primary Chat Model": {
ai_languageModel: [ ai_languageModel: [
[ [
{ {
node: "LLM Chain Evaluation", node: "LLM Chain Evaluation (Primary)",
type: "ai_languageModel", type: "ai_languageModel",
index: 0, index: 0,
}, },
@ -224,24 +401,13 @@ return [{
outputParser: [ outputParser: [
[ [
{ {
node: "LLM Chain Evaluation", node: "LLM Chain Evaluation (Primary)",
type: "outputParser", type: "outputParser",
index: 0, index: 0,
}, },
], ],
], ],
}, },
"LLM Chain Evaluation": {
main: [
[
{
node: "Format Evaluation Data",
type: "main",
index: 0,
},
],
],
},
"Format Evaluation Data": { "Format Evaluation Data": {
main: [ main: [
[ [
@ -264,10 +430,178 @@ return [{
], ],
], ],
}, },
};
if (configureFallback && fallbackConfig) {
console.log("Configuring Fallback LLM Route...");
const checkErrorNode = {
parameters: {
conditions: {
options: {
caseSensitive: true,
leftValue: "",
typeValidation: "strict",
},
conditions: [
{
id: "err-cond",
leftValue: "={{ $json.hasOwnProperty('error') }}",
rightValue: "true",
operator: {
type: "boolean",
operation: "equals",
},
},
],
combinator: "and",
},
},
id: "check-error",
name: "Check Primary Error",
type: "n8n-nodes-base.if",
typeVersion: 2.2,
position: [600, 300],
};
const fallbackChainNode = {
parameters: {
promptType: "defineBelow", // Fixed casing: camelCase!
hasOutputParser: true, // Enforce "Require Specific Output Format"
text: "={{ $('Webhook Trigger').item.json.body.text }}",
systemMessage: "You are an AI recruitment assistant evaluating a candidate's CV for a job vacancy. Analyze the candidate's CV text. You MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief candidate summary (max 3 sentences).\n- classification: 'Qualified', 'Unqualified', or 'Review'.\n- suggestions: an array of recommendations for next steps (e.g. ['Schedule interview', 'Reject', 'Verify references']).\n- riskLevel: 'Low', 'Medium', or 'High'.\n- ai_score: a number between 0 and 100 representing general suitability.\n\nDo not include markdown code blocks or any text outside the JSON.",
},
id: "llm-chain-fallback",
name: "LLM Chain Evaluation (Fallback)",
type: "@n8n/n8n-nodes-langchain.chainLlm",
typeVersion: 1.4,
position: [800, 450],
};
const fallbackModelNode = {
parameters: fallbackConfig.nodeParameters,
id: "fallback-model",
name: "Fallback Chat Model",
type: fallbackConfig.nodeType,
typeVersion: 1,
position: [750, 630],
credentials: {
[fallbackConfig.credentialType]: {
id: fallbackCredId,
name: `Semillero2_Fallback_${fallbackConfig.credentialType}`,
},
}, },
}; };
console.log("Deploying workflow to n8n..."); const jsonParserFallbackNode = {
parameters: {
jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"summary\": {\n \"type\": \"string\"\n },\n \"classification\": {\n \"type\": \"string\",\n \"enum\": [\"Qualified\", \"Unqualified\", \"Review\"]\n },\n \"suggestions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"riskLevel\": {\n \"type\": \"string\",\n \"enum\": [\"Low\", \"Medium\", \"High\"]\n },\n \"ai_score\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\"summary\", \"classification\", \"suggestions\", \"riskLevel\", \"ai_score\"]\n}",
},
id: "json-parser-fallback",
name: "Structured Output Parser (Fallback)",
type: "@n8n/n8n-nodes-langchain.outputParserStructured",
typeVersion: 1,
position: [880, 630],
};
// Reposition Set node for fallback routing
setNode.position = [1050, 450];
supabaseInsertNode.position = [1250, 450];
respondWebhookNode.position = [1450, 450];
wNodes.push(checkErrorNode, fallbackChainNode, fallbackModelNode, jsonParserFallbackNode);
// Primary chain routes to IF check
wConnections["LLM Chain Evaluation (Primary)"] = {
main: [
[
{
node: "Check Primary Error",
type: "main",
index: 0,
},
],
],
};
// IF Node routes
wConnections["Check Primary Error"] = {
main: [
[
{
node: "LLM Chain Evaluation (Fallback)",
type: "main",
index: 0,
},
], // true branch (Output 0 -> Error happened)
[
{
node: "Format Evaluation Data",
type: "main",
index: 0,
},
], // false branch (Output 1 -> Success)
],
};
// Connect fallback LLM components
wConnections["Fallback Chat Model"] = {
ai_languageModel: [
[
{
node: "LLM Chain Evaluation (Fallback)",
type: "ai_languageModel",
index: 0,
},
],
],
};
wConnections["Structured Output Parser (Fallback)"] = {
outputParser: [
[
{
node: "LLM Chain Evaluation (Fallback)",
type: "outputParser",
index: 0,
},
],
],
};
wConnections["LLM Chain Evaluation (Fallback)"] = {
main: [
[
{
node: "Format Evaluation Data",
type: "main",
index: 0,
},
],
],
};
} else {
// If no fallback, wire directly Primary Chain -> Set node
wConnections["LLM Chain Evaluation (Primary)"].main = [
[
{
node: "Format Evaluation Data",
type: "main",
index: 0,
},
],
];
}
// Define complete workflow object
const workflowDefinition = {
name: "Semillero2: End-to-End Candidate Evaluation",
settings: {},
nodes: wNodes,
connections: wConnections,
};
console.log("\nDeploying workflow to n8n...");
// Check if it already exists // Check if it already exists
const workflowsList = await n8nRequest("/api/v1/workflows"); const workflowsList = await n8nRequest("/api/v1/workflows");
const existingWf = workflowsList.data.find( const existingWf = workflowsList.data.find(