diff --git a/app/(dashboard)/candidates/api/parse-cv/route.ts b/app/(dashboard)/candidates/api/parse-cv/route.ts index 9e5eeda..b4708da 100644 --- a/app/(dashboard)/candidates/api/parse-cv/route.ts +++ b/app/(dashboard)/candidates/api/parse-cv/route.ts @@ -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, { diff --git a/app/webhook-test/route.ts b/app/webhook-test/route.ts new file mode 100644 index 0000000..d8d3a4a --- /dev/null +++ b/app/webhook-test/route.ts @@ -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 }); + } +} diff --git a/lib/embeddings.ts b/lib/embeddings.ts index 96e3a45..469670b 100644 --- a/lib/embeddings.ts +++ b/lib/embeddings.ts @@ -9,14 +9,14 @@ export async function generateEmbedding(text: string): Promise { 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 { 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; diff --git a/lib/supabase.ts b/lib/supabase.ts index bd511c4..50bfe65 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -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); }; diff --git a/next.config.ts b/next.config.ts index e9ffa30..4410b96 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + serverExternalPackages: ["pdf-parse"], }; export default nextConfig; diff --git a/scripts/deploy-n8n-v2.ts b/scripts/deploy-n8n-v2.ts index 0b5dd8a..95f192b 100644 --- a/scripts/deploy-n8n-v2.ts +++ b/scripts/deploy-n8n-v2.ts @@ -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 { - 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= Primary LLM provider (1/deepseek, 2/openai, 3/gemini/google, 4/anthropic/claude) [default: gemini] + --primary-model= Primary model name [default based on provider] + --primary-key= Primary API key [default: loaded from environment] + --fallback Enable fallback LLM model [default: false] + --fallback-provider= Fallback LLM provider (1/deepseek, 2/openai, 3/gemini/google, 4/anthropic/claude) [default: deepseek] + --fallback-model= Fallback model name [default based on provider] + --fallback-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= 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= 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("=============================================="); diff --git a/scripts/get-deployed-workflow.ts b/scripts/get-deployed-workflow.ts new file mode 100644 index 0000000..d777bac --- /dev/null +++ b/scripts/get-deployed-workflow.ts @@ -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); diff --git a/test-assets/curriculum-vitae-english.pdf b/test-assets/curriculum-vitae-english.pdf new file mode 100644 index 0000000..47cabea Binary files /dev/null and b/test-assets/curriculum-vitae-english.pdf differ