feat(deploy): make deploy tool non-interactive with cli flags

This commit is contained in:
Gabriel Ramos 2026-06-09 18:11:12 -04:00
parent fb4611a4e0
commit d74cdc445e
8 changed files with 344 additions and 94 deletions

View file

@ -88,9 +88,15 @@ export async function POST(request: NextRequest) {
);
}
const isTest = formData.get("isTest") === "true";
// Call n8n webhook
let n8nResponseData = null;
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
let webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
if (isTest && webhookUrl) {
webhookUrl = webhookUrl.replace("/webhook/", "/webhook-test/");
}
if (webhookUrl) {
try {
const n8nResponse = await fetch(webhookUrl, {

145
app/webhook-test/route.ts Normal file
View file

@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase";
import { generateEmbedding } from "@/lib/embeddings";
import fs from "fs/promises";
import path from "path";
export async function GET(request: NextRequest) {
try {
const supabase = createServerSupabaseClient();
const origin = request.nextUrl.origin;
// 1. Fetch or create a Job Vacancy for testing
let job = null;
const { data: existingJobs, error: jobsFetchError } = await supabase
.from("jobs")
.select("*")
.limit(1);
if (jobsFetchError) {
return NextResponse.json({ error: `Failed to fetch jobs: ${jobsFetchError.message}` }, { status: 500 });
}
if (existingJobs && existingJobs.length > 0) {
job = existingJobs[0];
} else {
// Create a default job if none exist
const title = "Senior AI Research Engineer";
const requirementsText = "We are seeking a Senior AI Research Engineer with expert knowledge in Large Language Models, PyTorch, LangChain, and agentic reasoning architectures.";
const embedding = await generateEmbedding(requirementsText);
const { data: newJob, error: jobInsertError } = await supabase
.from("jobs")
.insert({
title,
requirements: { text: requirementsText },
embedding,
})
.select("*")
.single();
if (jobInsertError || !newJob) {
return NextResponse.json({ error: `Failed to create mock job: ${jobInsertError?.message}` }, { status: 500 });
}
job = newJob;
}
// 2. Read the Curriculum Vitae test asset
const cvPath = path.join(process.cwd(), "test-assets", "curriculum-vitae-english.pdf");
let fileBuffer;
try {
fileBuffer = await fs.readFile(cvPath);
} catch (fsError) {
return NextResponse.json({
error: `Could not read test CV asset at ${cvPath}. Please ensure it exists. Detailed error: ${fsError instanceof Error ? fsError.message : fsError}`
}, { status: 400 });
}
const testMode = request.nextUrl.searchParams.get("testMode") !== "false";
// 3. Prepare Form Data for parse-cv endpoint
const formData = new FormData();
const fileBlob = new Blob([fileBuffer], { type: "application/pdf" });
formData.append("file", fileBlob, "curriculum-vitae-english.pdf");
formData.append("jobId", job.id);
formData.append("isTest", testMode ? "true" : "false");
// 4. Send POST request to local parse-cv API
const parseCvUrl = `${origin}/candidates/api/parse-cv`;
let parseResult;
try {
const parseResponse = await fetch(parseCvUrl, {
method: "POST",
body: formData,
});
if (!parseResponse.ok) {
const errText = await parseResponse.text();
return NextResponse.json({
error: `Parse-CV API returned non-OK status: ${parseResponse.status} - ${errText}`
}, { status: 500 });
}
parseResult = await parseResponse.json();
} catch (fetchError) {
return NextResponse.json({
error: `Failed to call local parse-cv route: ${fetchError instanceof Error ? fetchError.message : fetchError}`
}, { status: 500 });
}
const { candidateId, interviewId, n8nResponse } = parseResult;
// 5. Verification - Poll the Supabase `scores` table to verify n8n updated the DB
let scoreRecord = null;
let verificationAttempts = 0;
const maxAttempts = 10;
const delayMs = 1500;
for (let i = 0; i < maxAttempts; i++) {
verificationAttempts++;
// Wait for n8n execution to finish and write back
await new Promise((resolve) => setTimeout(resolve, delayMs));
const { data: score, error: scoreError } = await supabase
.from("scores")
.select("*")
.eq("candidate_id", candidateId)
.eq("interview_id", interviewId)
.maybeSingle();
if (score && !scoreError) {
scoreRecord = score;
break;
}
}
return NextResponse.json({
status: scoreRecord ? "success" : "completed_with_pending_evaluation",
message: scoreRecord
? "Pipeline test executed and verified successfully!"
: "Pipeline executed but AI evaluation write-back is pending or failed.",
testDetails: {
jobUsed: {
id: job.id,
title: job.title,
status: existingJobs && existingJobs.length > 0 ? "reused" : "created",
},
parseCvResponse: {
candidateId,
interviewId,
n8nWebhookResponse: n8nResponse,
},
verification: {
attempts: verificationAttempts,
verified: !!scoreRecord,
scoreData: scoreRecord,
}
}
});
} catch (error: unknown) {
console.error("Error in webhook-test API:", error);
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View file

@ -9,14 +9,14 @@ export async function generateEmbedding(text: string): Promise<number[]> {
try {
const start = Date.now();
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${apiKey}`,
`https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=${apiKey}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "models/text-embedding-004",
model: "models/gemini-embedding-001",
content: {
parts: [{ text }],
},
@ -41,15 +41,21 @@ export async function generateEmbedding(text: string): Promise<number[]> {
originalDimension: embedding.length,
}, Date.now() - start);
// Gemini text-embedding-004 outputs 768 dimensions.
// Pad with zeros to fit database vector(1536) schema limit.
// Adapt embedding dimensionality dynamically to fit the database vector(1536) schema limit.
const targetDimension = 1536;
const paddedEmbedding = [...embedding];
while (paddedEmbedding.length < targetDimension) {
paddedEmbedding.push(0.0);
let finalEmbedding = [...embedding];
if (finalEmbedding.length > targetDimension) {
// Truncate (Matryoshka Representation Learning allows this without loss of semantic meaning)
finalEmbedding = finalEmbedding.slice(0, targetDimension);
} else {
// Pad with zeros if the embedding is smaller
while (finalEmbedding.length < targetDimension) {
finalEmbedding.push(0.0);
}
}
return paddedEmbedding;
return finalEmbedding;
} catch (error) {
Logger.error("Failed to generate embedding", error);
throw error;

View file

@ -17,8 +17,15 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey);
// Server-side admin/secret Supabase client
export const createServerSupabaseClient = () => {
const secretKey = process.env.SUPABASE_SECRET_KEY;
if (!secretKey) {
throw new Error("Missing env.SUPABASE_SECRET_KEY");
const publishableKey = process.env.SUPABASE_PUBLISHABLE_KEY;
// Use secret key if available and not a placeholder; otherwise fall back to publishable key
const activeKey = (secretKey && secretKey !== "sb_secret_your_secret_key")
? secretKey
: publishableKey;
if (!activeKey) {
throw new Error("Missing env.SUPABASE_SECRET_KEY or SUPABASE_PUBLISHABLE_KEY");
}
return createClient(supabaseUrl, secretKey);
return createClient(supabaseUrl, activeKey);
};

View file

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
serverExternalPackages: ["pdf-parse"],
};
export default nextConfig;

View file

@ -1,6 +1,5 @@
import * as fs from "fs";
import * as path from "path";
import * as readline from "readline";
// Load .env variables
const envPath = path.join(__dirname, "../.env");
@ -21,28 +20,16 @@ if (fs.existsSync(envPath)) {
const N8N_HOST = process.env.N8N_HOST || "https://n8n.gaboggamer.online";
const N8N_API_KEY = process.env.N8N_API_KEY;
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SUPABASE_SECRET_KEY = process.env.SUPABASE_SECRET_KEY;
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
const SUPABASE_SECRET_KEY = (process.env.SUPABASE_SECRET_KEY && process.env.SUPABASE_SECRET_KEY !== "sb_secret_your_secret_key")
? process.env.SUPABASE_SECRET_KEY
: process.env.SUPABASE_PUBLISHABLE_KEY;
if (!N8N_API_KEY) {
console.error("Error: N8N_API_KEY is not defined in .env");
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) {
const response = await fetch(`${N8N_HOST}${endpoint}`, {
method,
@ -66,17 +53,17 @@ async function getOrCreateCredential(name: string, type: string, data: any) {
const credsList = await n8nRequest("/api/v1/credentials");
const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type);
if (existingCred) {
console.log(`Reusing existing credential: ${name} (ID: ${existingCred.id})`);
return existingCred.id;
} else {
const newCred = await n8nRequest("/api/v1/credentials", "POST", {
name,
type,
data,
});
console.log(`Created new credential: ${name} (ID: ${newCred.id})`);
return newCred.id;
console.log(`Deleting existing credential: ${name} (ID: ${existingCred.id})...`);
await n8nRequest(`/api/v1/credentials/${existingCred.id}`, "DELETE");
}
const newCred = await n8nRequest("/api/v1/credentials", "POST", {
name,
type,
data,
});
console.log(`Created new credential: ${name} (ID: ${newCred.id})`);
return newCred.id;
} catch (err: any) {
console.error(`Error setting up credential ${name}:`, err.message);
process.exit(1);
@ -151,66 +138,115 @@ function getProviderConfig(provider: string, apiKey: string, modelName: string):
}
}
function normalizeProvider(provider: string): string {
const p = provider.trim().toLowerCase();
if (p === "1" || p === "deepseek") return "1";
if (p === "2" || p === "openai") return "2";
if (p === "3" || p === "gemini" || p === "google") return "3";
if (p === "4" || p === "anthropic" || p === "claude") return "4";
throw new Error(`Invalid provider: "${provider}". Choose from: deepseek (1), openai (2), gemini (3), anthropic (4)`);
}
function getDefaultModel(provider: string): string {
if (provider === "1") return "deepseek-chat";
if (provider === "2") return "gpt-4o-mini";
if (provider === "3") return "gemini-1.5-flash";
if (provider === "4") return "claude-3-5-sonnet-latest";
throw new Error(`Invalid provider choice: ${provider}`);
}
function getApiKeyFromEnv(provider: string): string {
if (provider === "1") return process.env.DEEPSEEK_API_KEY || "";
if (provider === "2") return process.env.OPENAI_API_KEY || "";
if (provider === "3") return process.env.GEMINI_API_KEY || "";
if (provider === "4") return process.env.ANTHROPIC_API_KEY || "";
return "";
}
function printUsage() {
console.log(`
Usage: npx tsx scripts/deploy-n8n-v2.ts [options]
Options:
--primary-provider=<name|num> Primary LLM provider (1/deepseek, 2/openai, 3/gemini/google, 4/anthropic/claude) [default: gemini]
--primary-model=<model_name> Primary model name [default based on provider]
--primary-key=<api_key> Primary API key [default: loaded from environment]
--fallback Enable fallback LLM model [default: false]
--fallback-provider=<name|num> Fallback LLM provider (1/deepseek, 2/openai, 3/gemini/google, 4/anthropic/claude) [default: deepseek]
--fallback-model=<model_name> Fallback model name [default based on provider]
--fallback-key=<api_key> Fallback API key [default: loaded from environment]
-h, --help Show this help message
`);
}
async function main() {
console.log("\n==================================================");
console.log("Welcome to interactive n8n workflow deployment");
console.log("==================================================");
// Parse command line arguments
const args: any = {};
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
}
if (arg.startsWith("--")) {
const parts = arg.slice(2).split("=");
const key = parts[0];
const val = parts.length > 1 ? parts[1] : true;
args[key] = val;
}
}
// 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";
console.log("Running in non-interactive mode using CLI flags.");
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.");
let primaryProviderChoice: string;
try {
primaryProviderChoice = normalizeProvider(String(args["primary-provider"] || "gemini"));
} catch (err: any) {
console.error(err.message);
printUsage();
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";
const primaryModelName = String(args["primary-model"] || getDefaultModel(primaryProviderChoice));
const primaryApiKey = String(args["primary-key"] || getApiKeyFromEnv(primaryProviderChoice));
if (!primaryApiKey) {
console.error(`Error: API Key for primary provider (${primaryProviderChoice}) is required.`);
console.error(`Please provide --primary-key=<key> or set the corresponding environment variable (e.g. GEMINI_API_KEY).`);
printUsage();
process.exit(1);
}
const configureFallback = args["fallback"] === true || args["fallback"] === "true";
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.");
try {
fallbackProviderChoice = normalizeProvider(String(args["fallback-provider"] || "deepseek"));
} catch (err: any) {
console.error(err.message);
printUsage();
process.exit(1);
}
fallbackModelName = String(args["fallback-model"] || getDefaultModel(fallbackProviderChoice));
fallbackApiKey = String(args["fallback-key"] || getApiKeyFromEnv(fallbackProviderChoice));
if (!fallbackApiKey) {
console.error(`Error: API Key for fallback provider (${fallbackProviderChoice}) is required when fallback is enabled.`);
console.error(`Please provide --fallback-key=<key> or set the corresponding environment variable (e.g. DEEPSEEK_API_KEY).`);
printUsage();
process.exit(1);
}
}
console.log(`Primary Provider: ${primaryProviderChoice} (${primaryModelName})`);
if (configureFallback) {
console.log(`Fallback Provider: ${fallbackProviderChoice} (${fallbackModelName})`);
} else {
console.log("Fallback Provider: Disabled");
}
console.log("\nDeploying credentials to n8n...");
@ -325,21 +361,21 @@ async function main() {
},
],
},
options: {
includeOtherFields: false, // Drop other fields to cleanly match schema
},
include: "none",
options: {},
},
id: "format-data",
name: "Format Evaluation Data",
type: "n8n-nodes-base.set",
typeVersion: 3,
typeVersion: 3.4,
position: [700, 300],
};
const supabaseInsertNode = {
parameters: {
operation: "insert",
table: "scores",
operation: "create",
tableId: "scores",
dataToSend: "autoMapInputData",
options: {},
},
id: "supabase-insert",
@ -505,7 +541,7 @@ async function main() {
console.log(`Activating workflow (ID: ${deployResult.id})...`);
await n8nRequest(`/api/v1/workflows/${deployResult.id}/activate`, "POST");
const webhookUrl = `${N8N_HOST}/webhook/${deployResult.id}/webhook/evaluate-candidate`;
const webhookUrl = `${N8N_HOST}/webhook/evaluate-candidate`;
console.log("\n==============================================");
console.log("DEPLOYMENT COMPLETE");
console.log("==============================================");

View file

@ -0,0 +1,50 @@
import * as fs from "fs";
import * as path from "path";
// Load .env variables
const envPath = path.join(__dirname, "../.env");
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, "utf8");
for (const line of envContent.split("\n")) {
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
if (match) {
const key = match[1];
let value = match[2].trim();
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
process.env[key] = value;
}
}
}
const N8N_HOST = process.env.N8N_HOST || "https://n8n.gaboggamer.online";
const N8N_API_KEY = process.env.N8N_API_KEY;
if (!N8N_API_KEY) {
console.error("Error: N8N_API_KEY is not defined in .env");
process.exit(1);
}
async function main() {
const workflowId = "OOIXDZVnULjRBdjQ";
const response = await fetch(`${N8N_HOST}/api/v1/workflows/${workflowId}`, {
headers: {
"X-N8N-API-KEY": N8N_API_KEY!,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const text = await response.text();
console.error(`Failed to fetch workflow: ${response.status} - ${text}`);
process.exit(1);
}
const workflow = await response.json();
console.log("\n================ DEPLOYED NODES ================");
console.log(JSON.stringify(workflow.nodes, null, 2));
console.log("\n============= DEPLOYED CONNECTIONS =============");
console.log(JSON.stringify(workflow.connections, null, 2));
}
main().catch(console.error);

Binary file not shown.