Compare commits

..

No commits in common. "24c88915b0a92c09b6089f0b29f0197a7cdbf84a" and "bde47a32d74a04935e43849b7daa121684f74139" have entirely different histories.

14 changed files with 344 additions and 1472 deletions

View file

@ -1,153 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { candidateId, jobId } = body;
if (!candidateId || !jobId) {
return NextResponse.json(
{ error: "candidateId and jobId are required" },
{ status: 400 }
);
}
const supabase = createServerSupabaseClient();
// 1. Fetch candidate details
const { data: candidate, error: candidateError } = await supabase
.from("candidates")
.select("*")
.eq("id", candidateId)
.single();
if (candidateError || !candidate) {
return NextResponse.json(
{ error: candidateError?.message || "Candidate not found" },
{ status: 404 }
);
}
const contactInfo = candidate.contact_info as {
email?: string;
phone?: string;
skills?: string[];
summary?: string;
cv_text?: string;
};
const cvText = contactInfo.cv_text || "";
const email = contactInfo.email || "unknown@example.com";
const candidateName = candidate.name;
// 2. Fetch job details
const { data: job, error: jobError } = await supabase
.from("jobs")
.select("*")
.eq("id", jobId)
.single();
if (jobError || !job) {
return NextResponse.json(
{ error: jobError?.message || "Job not found" },
{ status: 404 }
);
}
const jobRequirementsText = (job.requirements as { text?: string })?.text || "";
// 3. Fetch or create interview record
let interviewId = "";
const { data: existingInterviews, error: fetchInterviewError } = await supabase
.from("interviews")
.select("id")
.eq("candidate_id", candidateId)
.eq("job_id", jobId)
.limit(1);
if (fetchInterviewError) {
return NextResponse.json(
{ error: fetchInterviewError.message },
{ status: 500 }
);
}
if (existingInterviews && existingInterviews.length > 0) {
interviewId = existingInterviews[0].id;
} else {
// Create new interview
const { data: newInterview, error: insertInterviewError } = await supabase
.from("interviews")
.insert({
candidate_id: candidateId,
job_id: jobId,
interview_date: new Date().toISOString(),
stage: "Screening",
})
.select("id")
.single();
if (insertInterviewError || !newInterview) {
return NextResponse.json(
{ error: insertInterviewError?.message || "Failed to create interview record" },
{ status: 500 }
);
}
interviewId = newInterview.id;
}
// 4. Call n8n webhook
let n8nResponseData = null;
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
if (webhookUrl) {
try {
const n8nResponse = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
candidateId,
interviewId,
candidateName,
candidateEmail: email,
text: cvText,
jobTitle: job.title,
jobRequirements: jobRequirementsText,
isTest: false,
}),
});
if (n8nResponse.ok) {
const contentType = n8nResponse.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
n8nResponseData = await n8nResponse.json();
} else {
n8nResponseData = { message: await n8nResponse.text() };
}
} else {
const errText = await n8nResponse.text();
n8nResponseData = { error: `n8n response not ok: ${n8nResponse.status} - ${errText}` };
}
} catch (err: unknown) {
n8nResponseData = { error: err instanceof Error ? err.message : "Failed to call n8n webhook" };
}
} else {
n8nResponseData = { error: "NEXT_PUBLIC_N8N_WEBHOOK_URL is not set" };
}
return NextResponse.json({
success: true,
candidateId,
interviewId,
candidateName,
n8nResponse: n8nResponseData,
});
} catch (error: unknown) {
console.error("Error in evaluate route:", error);
const errorMessage = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View file

@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase"; import { createServerSupabaseClient } from "@/lib/supabase";
import { generateEmbedding } from "@/lib/embeddings"; import { generateEmbedding } from "@/lib/embeddings";
import { PDFParse } from "pdf-parse"; import { PDFParse } from "pdf-parse";
import { extractCandidateProfile } from "@/lib/gemini";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@ -14,6 +13,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
} }
if (!jobId) {
return NextResponse.json({ error: "Missing jobId" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
@ -30,33 +33,30 @@ export async function POST(request: NextRequest) {
// Clean text // Clean text
const cleanText = text.replace(/\s+/g, " ").trim(); const cleanText = text.replace(/\s+/g, " ").trim();
// Extract professional profile using Gemini 1.5 Flash // Extract name from file (strip extension)
const profile = await extractCandidateProfile(cleanText); const name = file.name.replace(/\.[^/.]+$/, "");
// Extract email and phone using regex
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/;
const emailMatch = text.match(emailRegex);
const email = emailMatch ? emailMatch[0] : "unknown@example.com";
const phoneRegex = /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/;
const phoneMatch = text.match(phoneRegex);
const phone = phoneMatch ? phoneMatch[0] : "Not provided";
// Generate candidate embedding // Generate candidate embedding
const embedding = await generateEmbedding(cleanText); const embedding = await generateEmbedding(cleanText);
const isTest = formData.get("isTest") === "true";
let candidateId = "00000000-0000-0000-0000-000000000000";
let candidateName = profile.candidateName;
if (!isTest) {
// Initialize Supabase admin client // Initialize Supabase admin client
const supabase = createServerSupabaseClient(); const supabase = createServerSupabaseClient();
// Insert candidate with extracted details (including skills, summary, and cv_text) // Insert candidate
const { data: candidate, error: candidateError } = await supabase const { data: candidate, error: candidateError } = await supabase
.from("candidates") .from("candidates")
.insert({ .insert({
name: profile.candidateName, name,
contact_info: { contact_info: { email, phone },
email: profile.email,
phone: profile.phone,
skills: profile.skills || [],
summary: profile.summary || "",
cv_text: cleanText,
},
embedding, embedding,
}) })
.select("*") .select("*")
@ -69,30 +69,69 @@ export async function POST(request: NextRequest) {
); );
} }
candidateId = candidate.id; // Insert an initial interview
candidateName = candidate.name; const { data: interview, error: interviewError } = await supabase
// Create an initial interview record if jobId is provided (but do not trigger n8n evaluate webhook yet)
if (jobId) {
const { error: interviewError } = await supabase
.from("interviews") .from("interviews")
.insert({ .insert({
candidate_id: candidate.id, candidate_id: candidate.id,
job_id: jobId, job_id: jobId,
interview_date: new Date().toISOString(), interview_date: new Date().toISOString(),
stage: "Screening", stage: "Screening",
})
.select("*")
.single();
if (interviewError || !interview) {
return NextResponse.json(
{ error: interviewError?.message || "Failed to insert interview" },
{ status: 500 }
);
}
// Call n8n webhook
let n8nResponseData = null;
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
if (webhookUrl) {
try {
const n8nResponse = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
candidateId: candidate.id,
interviewId: interview.id,
candidateName: candidate.name,
candidateEmail: email,
text: cleanText,
}),
}); });
if (interviewError) {
console.error("Failed to insert interview:", interviewError.message); if (n8nResponse.ok) {
const contentType = n8nResponse.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
n8nResponseData = await n8nResponse.json();
} else {
n8nResponseData = { message: await n8nResponse.text() };
} }
} else {
const errText = await n8nResponse.text();
n8nResponseData = { error: `n8n response not ok: ${n8nResponse.status} - ${errText}` };
} }
} catch (err: unknown) {
n8nResponseData = { error: err instanceof Error ? err.message : "Failed to call n8n webhook" };
}
} else {
n8nResponseData = { message: "NEXT_PUBLIC_N8N_WEBHOOK_URL is not set" };
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
candidateId, candidateId: candidate.id,
candidateName, interviewId: interview.id,
profile, candidateName: candidate.name,
candidateEmail: email,
n8nResponse: n8nResponseData,
}); });
} catch (error: unknown) { } catch (error: unknown) {
console.error("Error in parse-cv route:", error); console.error("Error in parse-cv route:", error);

View file

@ -21,8 +21,6 @@ interface Candidate {
contact_info: { contact_info: {
email: string; email: string;
phone: string; phone: string;
skills?: string[];
summary?: string;
}; };
scores?: Score[]; scores?: Score[];
created_at: string; created_at: string;
@ -31,110 +29,40 @@ interface Candidate {
export default function CandidatesPage() { export default function CandidatesPage() {
const [candidates, setCandidates] = useState<Candidate[]>([]); const [candidates, setCandidates] = useState<Candidate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const fetchCandidates = () => { useEffect(() => {
let active = true;
fetch("/api/candidates") fetch("/api/candidates")
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("Failed to fetch candidates"); if (!res.ok) throw new Error("Failed to fetch candidates");
return res.json(); return res.json();
}) })
.then((data) => { .then((data) => {
if (active) {
setCandidates(data); setCandidates(data);
setLoading(false); setLoading(false);
}
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
if (active) {
setLoading(false); setLoading(false);
}
}); });
};
useEffect(() => { return () => {
fetchCandidates(); active = false;
};
}, []); }, []);
// Upload PDF CV in a vacuum
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type !== "application/pdf") {
setUploadError("Please upload a PDF file");
return;
}
try {
setUploading(true);
setUploadError(null);
setUploadSuccess(null);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/candidates/api/parse-cv", {
method: "POST",
body: formData,
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Failed to process CV");
}
const resData = await res.json();
setUploadSuccess(`CV for ${resData.candidateName || file.name} successfully parsed!`);
fetchCandidates();
} catch (err: unknown) {
setUploadError(err instanceof Error ? err.message : "Error uploading CV");
} finally {
setUploading(false);
e.target.value = "";
}
};
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900">Candidates</h1> <h1 className="text-2xl font-bold text-slate-900">Candidates</h1>
<p className="text-slate-600 text-sm"> <p className="text-slate-600 text-sm">
A list of all candidates parsed and analyzed by the AI recruitment pipeline. A list of all candidates parsed and analyzed by the AI recruitment pipeline.
</p> </p>
</div> </div>
</div>
{/* CV Uploader (Vacuum Ingestion) */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center">
<h3 className="text-sm font-semibold text-slate-900 mb-1">
Ingest Candidate CV (in a Vacuum)
</h3>
<p className="text-xs text-slate-500 mb-4 max-w-md">
Upload a candidate CV PDF to parse contact info, skills, and summary.
No job position will be associated initially, keeping the data isolated.
</p>
<label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200">
{uploading ? "Processing CV..." : "Upload CV File"}
<input
type="file"
accept=".pdf"
onChange={handleFileUpload}
disabled={uploading}
className="hidden"
/>
</label>
{uploadError && (
<p className="text-xs text-red-600 mt-3 font-semibold">
{uploadError}
</p>
)}
{uploadSuccess && (
<p className="text-xs text-green-600 mt-3 font-semibold">
{uploadSuccess}
</p>
)}
</div>
{loading ? ( {loading ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
@ -143,12 +71,18 @@ export default function CandidatesPage() {
) : candidates.length === 0 ? ( ) : candidates.length === 0 ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200"> <div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
<p className="text-slate-500 text-sm"> <p className="text-slate-500 text-sm">
No candidates found. Upload a CV above to get started. No candidates found. Upload CVs on the Jobs tab to parse them.
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-6"> <div className="grid grid-cols-1 gap-6">
{candidates.map((candidate) => { {candidates.map((candidate) => {
// Get the latest score
const latestScore =
candidate.scores && candidate.scores.length > 0
? candidate.scores[0]
: null;
return ( return (
<div <div
key={candidate.id} key={candidate.id}
@ -162,47 +96,70 @@ export default function CandidatesPage() {
</h2> </h2>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 mt-1">
Email:{" "} Email:{" "}
<span className="text-slate-600 font-medium mr-3"> <span className="text-slate-600 font-medium">
{candidate.contact_info.email} {candidate.contact_info.email}
</span> </span>
<span className="mx-2">|</span>
Phone:{" "} Phone:{" "}
<span className="text-slate-600 font-medium"> <span className="text-slate-600 font-medium">
{candidate.contact_info.phone} {candidate.contact_info.phone}
</span> </span>
</div> </div>
</div> </div>
{latestScore ? (
<div className="flex items-center gap-3">
<div className="text-right">
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wider">
AI Assessment
</span>
<span className="text-xl font-bold text-blue-600">
{latestScore.ai_score} / 10
</span>
</div>
<div className="px-3 py-1 bg-slate-50 text-slate-600 text-xs font-semibold rounded-md border border-slate-200">
{latestScore.evaluation.classification}
</div>
</div>
) : (
<div className="px-3 py-1 bg-slate-50 text-slate-500 text-xs font-semibold rounded-md border border-slate-200">
Pending Evaluation
</div>
)}
</div> </div>
{/* Extracted Profile (Summary & Skills) */} {/* Score details if available */}
<div className="flex flex-col gap-2"> {latestScore ? (
{candidate.contact_info.summary && ( <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div> <div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-1"> <span className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
Professional Summary (Extracted) AI Summary
</span> </span>
<p className="text-slate-600 text-sm leading-relaxed"> <p className="text-slate-600 leading-relaxed">
{candidate.contact_info.summary} {latestScore.evaluation.summary}
</p>
<div className="mt-2 text-xs text-slate-500">
Risk Level:{" "}
<span className="font-semibold text-slate-600">
{latestScore.evaluation.riskLevel}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
Action Items / Suggestions
</span>
<p className="text-slate-600 leading-relaxed whitespace-pre-line">
{latestScore.evaluation.suggestions}
</p> </p>
</div> </div>
</div>
) : (
<p className="text-slate-500 text-sm italic">
This candidate&apos;s CV has been indexed, but the AI evaluation
has not yet completed. The background n8n workflow updates
scores upon completion.
</p>
)} )}
{candidate.contact_info.skills && candidate.contact_info.skills.length > 0 && (
<div className="mt-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-1">
Skills & Technologies
</span>
<div className="flex flex-wrap gap-1.5">
{candidate.contact_info.skills.map((skill) => (
<span
key={skill}
className="px-2 py-0.5 bg-slate-50 text-slate-600 text-xs rounded border border-slate-200"
>
{skill}
</span>
))}
</div>
</div>
)}
</div>
</div> </div>
); );
})} })}

View file

@ -5,11 +5,7 @@ import React, { useState, useEffect } from "react";
interface Job { interface Job {
id: string; id: string;
title: string; title: string;
requirements: { requirements: { text: string };
text: string;
skills?: string[];
summary?: string;
};
created_at: string; created_at: string;
} }
@ -31,8 +27,6 @@ interface Candidate {
contact_info: { contact_info: {
email: string; email: string;
phone: string; phone: string;
skills?: string[];
summary?: string;
}; };
similarity?: number; similarity?: number;
scores?: Score[]; scores?: Score[];
@ -50,9 +44,6 @@ export default function JobsPage() {
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
// Evaluation states
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
// Form states // Form states
const [newTitle, setNewTitle] = useState(""); const [newTitle, setNewTitle] = useState("");
const [newRequirements, setNewRequirements] = useState(""); const [newRequirements, setNewRequirements] = useState("");
@ -186,8 +177,7 @@ export default function JobsPage() {
throw new Error(errData.error || "Failed to process CV"); throw new Error(errData.error || "Failed to process CV");
} }
const resData = await res.json(); setUploadSuccess(`CV for ${file.name} successfully parsed and indexed!`);
setUploadSuccess(`CV for ${resData.candidateName || file.name} successfully parsed and linked!`);
// Refresh matches for current job // Refresh matches for current job
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`); const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`);
@ -204,34 +194,6 @@ export default function JobsPage() {
} }
}; };
// Run deep AI evaluation via backend endpoint
const handleEvaluate = async (candidateId: string) => {
if (!selectedJob) return;
try {
setEvaluatingIds((prev) => ({ ...prev, [candidateId]: true }));
const res = await fetch("/candidates/api/evaluate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ candidateId, jobId: selectedJob.id }),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Failed to trigger evaluation");
}
// Refresh matches for current job
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`);
if (matchesRes.ok) {
const matchesData = await matchesRes.json();
setMatches(matchesData);
}
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Error evaluating candidate");
} finally {
setEvaluatingIds((prev) => ({ ...prev, [candidateId]: false }));
}
};
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Create Form & Vacancies List */} {/* Left Column: Create Form & Vacancies List */}
@ -330,42 +292,23 @@ export default function JobsPage() {
</p> </p>
</div> </div>
{/* Requirements & Extracted Job Skills */} {/* Requirements */}
<div className="p-4 bg-slate-50 rounded-md border border-slate-200 flex flex-col gap-3"> <div className="p-4 bg-slate-50 rounded-md border border-slate-200">
<div> <h3 className="text-sm font-semibold text-slate-900 mb-2">
<h3 className="text-sm font-semibold text-slate-900 mb-1"> Requirements
Description
</h3> </h3>
<p className="text-slate-600 text-sm whitespace-pre-wrap leading-relaxed"> <p className="text-slate-600 text-sm whitespace-pre-wrap">
{selectedJob.requirements.text} {selectedJob.requirements.text}
</p> </p>
</div> </div>
{selectedJob.requirements.skills && selectedJob.requirements.skills.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
Extracted Job Keywords / Required Skills
</h3>
<div className="flex flex-wrap gap-1.5">
{selectedJob.requirements.skills.map((skill) => (
<span
key={skill}
className="px-2 py-0.5 bg-white border border-slate-200 text-slate-700 text-xs rounded-md font-medium"
>
{skill}
</span>
))}
</div>
</div>
)}
</div>
{/* PDF Uploader */} {/* PDF Uploader */}
<div className="border border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center text-center"> <div className="border border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center text-center">
<h3 className="text-sm font-semibold text-slate-900 mb-1"> <h3 className="text-sm font-semibold text-slate-900 mb-1">
Upload Candidate CV for this Vacancy (PDF) Upload Candidate CV (PDF)
</h3> </h3>
<p className="text-xs text-slate-500 mb-4 max-w-md"> <p className="text-xs text-slate-500 mb-4 max-w-md">
Uploading a candidate CV parses the text and extracts their skills/profile in a vacuum. It associates them with this job, enabling you to check skills overlap before running the deep AI score model. Uploading a candidate CV parses the text, calculates its semantic matching score, schedules a screening interview, and signals n8n workflow.
</p> </p>
<label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200"> <label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200">
{uploading ? "Processing CV..." : "Choose CV File"} {uploading ? "Processing CV..." : "Choose CV File"}
@ -392,7 +335,7 @@ export default function JobsPage() {
{/* Matches List */} {/* Matches List */}
<div> <div>
<h3 className="text-base font-bold text-slate-900 mb-3"> <h3 className="text-base font-bold text-slate-900 mb-3">
Candidates & Compatibility Index Matched Candidates (Semantic Similarity)
</h3> </h3>
{loadingMatches ? ( {loadingMatches ? (
<p className="text-slate-500 text-sm">Finding matches...</p> <p className="text-slate-500 text-sm">Finding matches...</p>
@ -401,158 +344,46 @@ export default function JobsPage() {
No candidates have been uploaded or matched yet. No candidates have been uploaded or matched yet.
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
{matches.map((match) => { {matches.map((match) => {
const similarityPct = match.similarity const similarityPct = match.similarity
? Math.round(match.similarity * 100) ? Math.round(match.similarity * 100)
: null; : null;
const latestScore = match.scores?.[0]; const latestScore = match.scores?.[0];
// Programmatic skills matching logic
const jobSkills = selectedJob.requirements.skills || [];
const candidateSkills = match.contact_info.skills || [];
const skillsMatch = (candSkill: string, jobSkill: string): boolean => {
const c = candSkill.toLowerCase().trim();
const j = jobSkill.toLowerCase().trim();
if (c === j) return true;
if (c.includes(j) || j.includes(c)) return true;
const cWords = c.split(/[\s,./()&+-]+/).filter(w => w.length > 2);
const jWords = j.split(/[\s,./()&+-]+/).filter(w => w.length > 2);
const stopWords = ['and', 'for', 'with', 'the', 'management', 'administration', 'development', 'developer', 'engineer', 'system', 'systems', 'integration', 'operations', 'knowledge', 'experience', 'expert', 'proficiency', 'proficient'];
const sharedWords = cWords.filter(w => jWords.includes(w) && !stopWords.includes(w));
return sharedWords.length > 0;
};
const matchedSkills = jobSkills.filter(js =>
candidateSkills.some(cs => skillsMatch(cs, js))
);
const missingSkills = jobSkills.filter(js =>
!candidateSkills.some(cs => skillsMatch(cs, js))
);
const overlapCount = matchedSkills.length;
const totalRequired = jobSkills.length;
const matchPct = totalRequired > 0 ? Math.round((overlapCount / totalRequired) * 100) : 0;
const isPotentialMatch = matchPct >= 75;
return ( return (
<div <div
key={match.id} key={match.id}
className="p-5 rounded-lg border border-slate-200 bg-white flex flex-col gap-4 shadow-sm hover:border-slate-300 transition duration-200" className="p-4 rounded-md border border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
> >
{/* Upper info panel */}
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="text-slate-900 font-bold text-base"> <div className="text-slate-900 font-bold text-sm">
{match.name} {match.name}
</div> </div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
Email: <span className="text-slate-700 font-medium mr-3">{match.contact_info.email}</span> Email: {match.contact_info.email} | Phone:{" "}
Phone: <span className="text-slate-700 font-medium">{match.contact_info.phone}</span> {match.contact_info.phone}
</div> </div>
{latestScore && (
<div className="text-xs text-slate-600 mt-1">
<span className="font-semibold">AI Decision:</span>{" "}
{latestScore.evaluation.classification} (Score:{" "}
{latestScore.ai_score}/10)
</div> </div>
)}
<div className="flex flex-wrap items-center gap-2"> </div>
{/* Pre-selection status badge */} <div className="flex items-center gap-4">
<span
className={`px-2.5 py-1 text-xs font-semibold rounded-md border ${
isPotentialMatch
? "bg-green-50 text-green-700 border-green-200"
: "bg-slate-50 text-slate-500 border-slate-200"
}`}
>
{isPotentialMatch
? `Potential Match (${matchPct}% overlap)`
: `Skill Mismatch (${matchPct}% overlap)`}
</span>
{/* Semantic embedding similarity badge */}
{similarityPct !== null && ( {similarityPct !== null && (
<span className="px-2.5 py-1 text-xs font-semibold rounded-md border bg-blue-50 text-blue-700 border-blue-200"> <div className="text-right">
Semantic: {similarityPct}% <span className="block text-xs font-semibold text-slate-500 uppercase tracking-wider">
Match Score
</span> </span>
)} <span className="text-lg font-bold text-blue-600">
</div> {similarityPct}%
</div>
{/* Skills overlap details */}
<div className="bg-slate-50 p-3 rounded-md border border-slate-100 flex flex-col gap-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
Skills Check: {overlapCount} of {totalRequired} matching
</div>
<div className="flex flex-wrap gap-1.5">
{/* Display matched skills in green */}
{matchedSkills.map(skill => (
<span
key={skill}
className="px-2 py-0.5 bg-green-100 text-green-800 border border-green-200 text-xs rounded-md font-medium"
>
{skill}
</span> </span>
))}
{/* Display missing skills in light red/gray dashed */}
{missingSkills.map(skill => (
<span
key={skill}
className="px-2 py-0.5 bg-white border border-slate-200 border-dashed text-slate-400 text-xs rounded-md"
>
{skill} (missing)
</span>
))}
{/* Fallback if no skills are loaded */}
{jobSkills.length === 0 && (
<span className="text-xs text-slate-500 italic">
No required skills extracted for this job yet.
</span>
)}
</div>
</div>
{/* Bottom evaluation / action panel */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-3 border-t border-slate-100">
<div>
{latestScore ? (
<div className="flex flex-col gap-1">
<div className="text-xs text-slate-500">
AI ASSESSMENT RESULT
</div>
<div className="text-sm text-slate-700 font-medium">
Decision: <span className="font-bold text-slate-900">{latestScore.evaluation.classification}</span>
<span className="mx-2 font-normal text-slate-300">|</span>
Score: <span className="font-bold text-blue-600 text-base">{latestScore.ai_score} / 100</span>
</div>
<div className="text-xs text-slate-500 leading-normal max-w-lg mt-1">
{latestScore.evaluation.summary}
</div>
</div>
) : (
<div className="text-xs text-slate-500 italic">
Ready for deep assessment. Only potential matches recommended for LLM budget optimization.
</div> </div>
)} )}
</div> </div>
<div className="self-end sm:self-center">
<button
onClick={() => handleEvaluate(match.id)}
disabled={evaluatingIds[match.id]}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 shadow-sm"
>
{evaluatingIds[match.id]
? "Evaluating (n8n)..."
: latestScore
? "Re-run Deep AI"
: "Run Deep AI Evaluation"}
</button>
</div>
</div>
</div> </div>
); );
})} })}

View file

@ -67,43 +67,16 @@ export async function GET(request: NextRequest) {
const candidateIds = candidatesList.map((c) => c.id); const candidateIds = candidatesList.map((c) => c.id);
const { data: scores, error: scoresError } = await supabase const { data: scores, error: scoresError } = await supabase
.from("scores") .from("scores")
.select("*, interviews!inner(job_id)") .select("*")
.in("candidate_id", candidateIds) .in("candidate_id", candidateIds);
.eq("interviews.job_id", jobId)
.order("created_at", { ascending: false });
if (!scoresError && scores) { if (!scoresError && scores) {
const typedScores = (scores as unknown as CandidateScore[]) || []; const typedScores = (scores as unknown as CandidateScore[]) || [];
// Normalize scores on the fly (convert 0.88 to 88, 7.5 to 75) and enforce classification rules
typedScores.forEach((s) => {
if (s.ai_score <= 1.0) {
s.ai_score = Math.round(s.ai_score * 100);
} else if (s.ai_score <= 10.0) {
s.ai_score = Math.round(s.ai_score * 10);
} else {
s.ai_score = Math.round(s.ai_score);
}
// Enforce classification rules:
// 1. If score < 50, it MUST be Unqualified
// 2. If score is between 50 and 74, and classification is Qualified, downgrade to Review
if (s.ai_score < 50) {
s.evaluation.classification = "Unqualified";
} else if (s.ai_score >= 50 && s.ai_score < 75) {
if (s.evaluation.classification === "Qualified") {
s.evaluation.classification = "Review";
}
}
});
// Merge scores into rankedCandidates // Merge scores into rankedCandidates
const scoresMap = new Map<string, CandidateScore[]>(); const scoresMap = new Map<string, CandidateScore[]>();
typedScores.forEach((s) => { typedScores.forEach((s) => {
const list = scoresMap.get(s.candidate_id) || []; const list = scoresMap.get(s.candidate_id) || [];
list.push(s); list.push(s);
// Double safeguard: sort list descending by created_at
list.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
scoresMap.set(s.candidate_id, list); scoresMap.set(s.candidate_id, list);
}); });
@ -119,44 +92,17 @@ export async function GET(request: NextRequest) {
return NextResponse.json(candidatesList); return NextResponse.json(candidatesList);
} else { } else {
// Fetch all candidates sorted by created_at descending, along with scores ordered descending // Fetch all candidates sorted by created_at descending
const { data: candidates, error } = await supabase const { data: candidates, error } = await supabase
.from("candidates") .from("candidates")
.select("*, scores(*)") .select("*, scores(*)")
.order("created_at", { ascending: false }) .order("created_at", { ascending: false });
.order("created_at", { referencedTable: "scores", ascending: false });
if (error) { if (error) {
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });
} }
// Safeguard: Sort and normalize scores inside each candidate in Javascript as well return NextResponse.json(candidates);
const typedCandidates = candidates || [];
typedCandidates.forEach(cand => {
if (cand.scores && Array.isArray(cand.scores)) {
cand.scores.forEach((s: CandidateScore) => {
if (s.ai_score <= 1.0) {
s.ai_score = Math.round(s.ai_score * 100);
} else if (s.ai_score <= 10.0) {
s.ai_score = Math.round(s.ai_score * 10);
} else {
s.ai_score = Math.round(s.ai_score);
}
// Enforce classification rules:
if (s.ai_score < 50) {
s.evaluation.classification = "Unqualified";
} else if (s.ai_score >= 50 && s.ai_score < 75) {
if (s.evaluation.classification === "Qualified") {
s.evaluation.classification = "Review";
}
}
});
cand.scores.sort((a: CandidateScore, b: CandidateScore) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}
});
return NextResponse.json(typedCandidates);
} }
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Internal Server Error"; const errorMessage = error instanceof Error ? error.message : "Internal Server Error";

View file

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase"; import { createServerSupabaseClient } from "@/lib/supabase";
import { generateEmbedding } from "@/lib/embeddings"; import { generateEmbedding } from "@/lib/embeddings";
import { extractJobProfile } from "@/lib/gemini";
export async function GET() { export async function GET() {
try { try {
@ -31,7 +30,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Title and requirements are required" }, { status: 400 }); return NextResponse.json({ error: "Title and requirements are required" }, { status: 400 });
} }
const jobProfile = await extractJobProfile(requirements);
const embedding = await generateEmbedding(requirements); const embedding = await generateEmbedding(requirements);
const supabase = createServerSupabaseClient(); const supabase = createServerSupabaseClient();
@ -39,11 +37,7 @@ export async function POST(request: NextRequest) {
.from("jobs") .from("jobs")
.insert({ .insert({
title, title,
requirements: { requirements: { text: requirements },
text: requirements,
skills: jobProfile.skills,
summary: jobProfile.summary
},
embedding, embedding,
}) })
.select("*") .select("*")

View file

@ -1,167 +0,0 @@
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;
let scoreRecord = null;
let verified = false;
let verificationAttempts = 0;
if (testMode) {
// In test mode, we skip DB insertion, so n8n returns the structured response directly.
// Let's verify that the response contains the expected evaluation structure.
const hasEvaluation = n8nResponse && typeof n8nResponse === "object" && "evaluation" in n8nResponse;
const hasAiScore = n8nResponse && typeof n8nResponse === "object" && "ai_score" in n8nResponse;
if (hasEvaluation && hasAiScore) {
verified = true;
scoreRecord = n8nResponse;
}
} else {
// In live mode, we poll the DB to verify, but we MUST clean it up immediately afterwards
const maxAttempts = 10;
const delayMs = 1500;
for (let i = 0; i < maxAttempts; i++) {
verificationAttempts++;
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;
verified = true;
break;
}
}
// Cleanup immediately to avoid database clutter!
if (candidateId && candidateId !== "00000000-0000-0000-0000-000000000000") {
console.log(`Cleaning up test candidate: ${candidateId}`);
await supabase.from("scores").delete().eq("candidate_id", candidateId);
await supabase.from("interviews").delete().eq("candidate_id", candidateId);
await supabase.from("candidates").delete().eq("id", candidateId);
}
}
return NextResponse.json({
status: verified ? "success" : "completed_with_pending_evaluation",
message: verified
? (testMode ? "Pipeline test executed and verified successfully (In-Memory / No DB Write)!" : "Pipeline test executed, verified, and cleaned successfully from DB!")
: "Pipeline executed but AI evaluation verification 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,
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 { try {
const start = Date.now(); const start = Date.now();
const response = await fetch( const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=${apiKey}`, `https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${apiKey}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
model: "models/gemini-embedding-001", model: "models/text-embedding-004",
content: { content: {
parts: [{ text }], parts: [{ text }],
}, },
@ -41,21 +41,15 @@ export async function generateEmbedding(text: string): Promise<number[]> {
originalDimension: embedding.length, originalDimension: embedding.length,
}, Date.now() - start); }, Date.now() - start);
// Adapt embedding dimensionality dynamically to fit the database vector(1536) schema limit. // Gemini text-embedding-004 outputs 768 dimensions.
// Pad with zeros to fit database vector(1536) schema limit.
const targetDimension = 1536; const targetDimension = 1536;
let finalEmbedding = [...embedding]; const paddedEmbedding = [...embedding];
while (paddedEmbedding.length < targetDimension) {
if (finalEmbedding.length > targetDimension) { paddedEmbedding.push(0.0);
// 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 finalEmbedding; return paddedEmbedding;
} catch (error) { } catch (error) {
Logger.error("Failed to generate embedding", error); Logger.error("Failed to generate embedding", error);
throw error; throw error;

View file

@ -1,122 +0,0 @@
import { Logger } from "./logger";
export interface CandidateProfile {
candidateName: string;
email: string;
phone: string;
skills: string[];
summary: string;
}
export interface JobProfile {
skills: string[];
summary: string;
}
export async function extractCandidateProfile(text: string): Promise<CandidateProfile> {
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
const baseUrl = webhookUrl
? webhookUrl.replace(/\/evaluate-candidate$/, "")
: "https://n8n.gaboggamer.online/webhook";
const targetUrl = `${baseUrl}/extract-candidate-profile`;
try {
const start = Date.now();
const response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`n8n candidate profiling error: ${response.status} - ${errText}`);
}
const data = await response.json();
// In n8n response, it might be inside an array or directly the object.
let profile = Array.isArray(data) ? data[0] : data;
// Extract nested json property if present
if (profile && profile.json) {
profile = profile.json;
}
if (!profile || !profile.candidateName) {
throw new Error("Invalid response structure from n8n candidate profiling");
}
// Normalize skills to lowercase
if (Array.isArray(profile.skills)) {
profile.skills = profile.skills.map((s: string) => s.toLowerCase().trim());
} else {
profile.skills = [];
}
Logger.info("Extracted candidate profile via n8n successfully", {
candidateName: profile.candidateName,
skillsCount: profile.skills?.length,
}, Date.now() - start);
return profile as CandidateProfile;
} catch (error) {
Logger.error("Failed to extract candidate profile via n8n", error);
throw error;
}
}
export async function extractJobProfile(requirements: string): Promise<JobProfile> {
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
const baseUrl = webhookUrl
? webhookUrl.replace(/\/evaluate-candidate$/, "")
: "https://n8n.gaboggamer.online/webhook";
const targetUrl = `${baseUrl}/extract-job-profile`;
try {
const start = Date.now();
const response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ requirements }),
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`n8n job profiling error: ${response.status} - ${errText}`);
}
const data = await response.json();
let profile = Array.isArray(data) ? data[0] : data;
if (profile && profile.json) {
profile = profile.json;
}
if (!profile || !profile.skills) {
throw new Error("Invalid response structure from n8n job profiling");
}
// Normalize skills to lowercase
if (Array.isArray(profile.skills)) {
profile.skills = profile.skills.map((s: string) => s.toLowerCase().trim());
} else {
profile.skills = [];
}
Logger.info("Extracted job profile via n8n successfully", {
skillsCount: profile.skills?.length,
}, Date.now() - start);
return profile as JobProfile;
} catch (error) {
Logger.error("Failed to extract job profile via n8n", error);
throw error;
}
}

View file

@ -1,14 +1,14 @@
import { createClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabasePublishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY; const supabasePublishableKey = process.env.SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl) { if (!supabaseUrl) {
throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_URL"); throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_URL");
} }
if (!supabasePublishableKey) { if (!supabasePublishableKey) {
throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY or env.SUPABASE_PUBLISHABLE_KEY"); throw new Error("Missing env.SUPABASE_PUBLISHABLE_KEY");
} }
// Client-side/public Supabase client // Client-side/public Supabase client
@ -17,15 +17,8 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey);
// Server-side admin/secret Supabase client // Server-side admin/secret Supabase client
export const createServerSupabaseClient = () => { export const createServerSupabaseClient = () => {
const secretKey = process.env.SUPABASE_SECRET_KEY; const secretKey = process.env.SUPABASE_SECRET_KEY;
const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY; if (!secretKey) {
throw new Error("Missing env.SUPABASE_SECRET_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, activeKey); return createClient(supabaseUrl, secretKey);
}; };

View file

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

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");
@ -20,16 +21,28 @@ 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 SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY; const SUPABASE_SECRET_KEY = process.env.SUPABASE_SECRET_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) { if (!N8N_API_KEY) {
console.error("Error: N8N_API_KEY is not defined in .env"); console.error("Error: N8N_API_KEY is not defined in .env");
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,
@ -53,10 +66,9 @@ async function getOrCreateCredential(name: string, type: string, data: any) {
const credsList = await n8nRequest("/api/v1/credentials"); const credsList = await n8nRequest("/api/v1/credentials");
const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type); const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type);
if (existingCred) { if (existingCred) {
console.log(`Deleting existing credential: ${name} (ID: ${existingCred.id})...`); console.log(`Reusing existing credential: ${name} (ID: ${existingCred.id})`);
await n8nRequest(`/api/v1/credentials/${existingCred.id}`, "DELETE"); return existingCred.id;
} } else {
const newCred = await n8nRequest("/api/v1/credentials", "POST", { const newCred = await n8nRequest("/api/v1/credentials", "POST", {
name, name,
type, type,
@ -64,6 +76,7 @@ async function getOrCreateCredential(name: string, type: string, data: any) {
}); });
console.log(`Created new credential: ${name} (ID: ${newCred.id})`); console.log(`Created new credential: ${name} (ID: ${newCred.id})`);
return newCred.id; return newCred.id;
}
} catch (err: any) { } catch (err: any) {
console.error(`Error setting up credential ${name}:`, err.message); console.error(`Error setting up credential ${name}:`, err.message);
process.exit(1); process.exit(1);
@ -138,117 +151,68 @@ 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: deepseek]
--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: gemini]
--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() { async function main() {
// Parse command line arguments console.log("\n==================================================");
const args: any = {}; console.log("Welcome to interactive n8n workflow deployment");
for (let i = 2; i < process.argv.length; i++) { console.log("==================================================");
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;
}
}
console.log("Running in non-interactive mode using CLI flags."); // 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 primaryProviderChoice: string; let defaultModel = "gemini-1.5-flash";
try { if (primaryProviderChoice === "1") defaultModel = "deepseek-chat";
primaryProviderChoice = normalizeProvider(String(args["primary-provider"] || "deepseek")); else if (primaryProviderChoice === "2") defaultModel = "gpt-4o-mini";
} catch (err: any) { else if (primaryProviderChoice === "4") defaultModel = "claude-3-5-sonnet-latest";
console.error(err.message);
printUsage();
process.exit(1);
}
const primaryModelName = String(args["primary-model"] || getDefaultModel(primaryProviderChoice)); const primaryModelName = (await askQuestion(`Enter primary model name [default: ${defaultModel}]: `)) || defaultModel;
const primaryApiKey = String(args["primary-key"] || getApiKeyFromEnv(primaryProviderChoice));
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) { if (!primaryApiKey) {
console.error(`Error: API Key for primary provider (${primaryProviderChoice}) is required.`); console.error("Primary API Key is required.");
console.error(`Please provide --primary-key=<key> or set the corresponding environment variable (e.g. DEEPSEEK_API_KEY).`);
printUsage();
process.exit(1); process.exit(1);
} }
const configureFallback = args["fallback"] === true || args["fallback"] === "true"; // 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 fallbackProviderChoice = "";
let fallbackModelName = ""; let fallbackModelName = "";
let fallbackApiKey = ""; let fallbackApiKey = "";
if (configureFallback) { if (configureFallback) {
try { console.log("\nSelect Fallback LLM Provider:");
fallbackProviderChoice = normalizeProvider(String(args["fallback-provider"] || "gemini")); console.log("1. Deepseek (Native Node)");
} catch (err: any) { console.log("2. OpenAI (Standard)");
console.error(err.message); console.log("3. Google Gemini");
printUsage(); console.log("4. Anthropic");
process.exit(1); fallbackProviderChoice = (await askQuestion("Enter choice (1-4) [default: 1]: ")) || "1";
}
fallbackModelName = String(args["fallback-model"] || getDefaultModel(fallbackProviderChoice)); let defaultFallbackModel = "deepseek-chat";
fallbackApiKey = String(args["fallback-key"] || getApiKeyFromEnv(fallbackProviderChoice)); 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) { if (!fallbackApiKey) {
console.error(`Error: API Key for fallback provider (${fallbackProviderChoice}) is required when fallback is enabled.`); console.error("Fallback API Key is required.");
console.error(`Please provide --fallback-key=<key> or set the corresponding environment variable (e.g. GEMINI_API_KEY).`);
printUsage();
process.exit(1); 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..."); console.log("\nDeploying credentials to n8n...");
const supabaseCredId = await getOrCreateCredential("Semillero2_Supabase_V3", "supabaseApi", { const supabaseCredId = await getOrCreateCredential("Semillero2_Supabase_V3", "supabaseApi", {
host: SUPABASE_URL, host: SUPABASE_URL,
@ -277,45 +241,42 @@ async function main() {
// 3. Define workflow nodes dynamically // 3. Define workflow nodes dynamically
console.log("\nBuilding workflow nodes..."); console.log("\nBuilding workflow nodes...");
// BRANCH A: Candidate Evaluation Webhook Branch const webhookTriggerNode = {
const evalWebhookNode = {
parameters: { parameters: {
httpMethod: "POST", httpMethod: "POST",
path: "evaluate-candidate", path: "evaluate-candidate",
responseMode: "responseNode", responseMode: "responseNode",
options: {}, options: {},
}, },
id: "eval-webhook-trigger", id: "webhook-trigger",
name: "Webhook Trigger", name: "Webhook Trigger",
type: "n8n-nodes-base.webhook", type: "n8n-nodes-base.webhook",
typeVersion: 1.1, typeVersion: 1.1,
position: [100, 200], position: [100, 300],
}; };
const evalChainNode = { const primaryChainNode = {
parameters: { parameters: {
promptType: "define", promptType: "define", // Correct underlying value for manually defining prompt!
hasOutputParser: true, hasOutputParser: true, // Enforce "Require Specific Output Format"
needsFallback: configureFallback, hasFallbackModel: configureFallback, // Enable fallback model natively on the Chain node!
enableFallbackModel: configureFallback, text: "={{ $('Webhook Trigger').item.json.body.text }}",
hasFallbackModel: configureFallback, 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.",
text: "=Candidate CV Text:\n{{ $('Webhook Trigger').item.json.body.text }}\n\nTarget Job Vacancy:\nTitle: {{ $('Webhook Trigger').item.json.body.jobTitle }}\nRequirements:\n{{ $('Webhook Trigger').item.json.body.jobRequirements }}",
systemMessage: "You are an AI recruitment evaluation expert. Your job is to carefully assess if the candidate qualifies for the specific job vacancy. Evaluate their CV text against the target Job Title and Job Requirements. Be objective: if the candidate does not have the core stack, experience, or skills required for this specific job, they MUST be classified as 'Unqualified' with a low suitability score.\n\nSuitability Score (ai_score) Calibration Rules:\n- If the candidate does not match the vacancy at all, or lacks all core technical skills required for the job, the score MUST be extremely low (between 0 and 15). Never give a middle score like 50 to a complete mismatch.\n- If the candidate has minor overlaps but lacks the core tech stack/experience, the score MUST be below 50.\n- If the candidate is a borderline or partial fit (50-74% match), the score must be between 50 and 74.\n- Only candidates who are highly qualified and match the core stack and experience should receive a score of 75 or higher.\n\nYou MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief evaluation summary explaining why they match or fail to match the specific job requirements (max 3 sentences).\n- classification: 'Qualified' (if they match the requirements well), 'Unqualified' (if they lack critical skills/stack for this specific job), or 'Review' (if they are a borderline match).\n- suggestions: an array of recommendations (e.g. ['Schedule technical interview', 'Reject', 'Verify experience with X']).\n- riskLevel: 'Low', 'Medium', or 'High' (suitability/fit risk).\n- ai_score: an integer between 0 and 100 representing suitability for this specific job. Return a whole integer (do NOT return a decimal fraction like 0.88, return an integer like 88).",
}, },
id: "eval-llm-chain", id: "llm-chain-primary",
name: "LLM Chain Evaluation (Primary)", name: "LLM Chain Evaluation (Primary)",
type: "@n8n/n8n-nodes-langchain.chainLlm", type: "@n8n/n8n-nodes-langchain.chainLlm",
typeVersion: 1.9, typeVersion: 1.4,
position: [400, 200], position: [400, 300],
}; };
const evalPrimaryModelNode = { const primaryModelNode = {
parameters: primaryConfig.nodeParameters, parameters: primaryConfig.nodeParameters,
id: "eval-primary-model", id: "primary-model",
name: "Primary Chat Model", name: "Primary Chat Model",
type: primaryConfig.nodeType, type: primaryConfig.nodeType,
typeVersion: 1, typeVersion: 1,
position: [300, 380], position: [300, 480],
credentials: { credentials: {
[primaryConfig.credentialType]: { [primaryConfig.credentialType]: {
id: primaryCredId, id: primaryCredId,
@ -324,18 +285,19 @@ async function main() {
}, },
}; };
const evalJsonParserNode = { 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\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100,\n \"description\": \"An integer score between 0 and 100 representing suitability. 100 means perfect fit, 0 means no fit.\"\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}",
}, },
id: "eval-json-parser", id: "json-parser",
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: [430, 380], position: [430, 480],
}; };
const evalSetNode = { // Replace Code node with a native Edit Fields (Set) node to avoid code blocks
const setNode = {
parameters: { parameters: {
assignments: { assignments: {
assignments: [ assignments: [
@ -351,7 +313,7 @@ async function main() {
}, },
{ {
name: "ai_score", name: "ai_score",
value: "={{ $json.ai_score <= 1 ? Math.round($json.ai_score * 100) : ($json.ai_score <= 10 ? Math.round($json.ai_score * 10) : Math.round($json.ai_score)) }}", value: "={{ $json.ai_score }}",
type: "number", type: "number",
}, },
{ {
@ -361,58 +323,28 @@ async function main() {
}, },
], ],
}, },
include: "none", options: {
options: {}, includeOtherFields: false, // Drop other fields to cleanly match schema
}, },
id: "eval-format-data", },
id: "format-data",
name: "Format Evaluation Data", name: "Format Evaluation Data",
type: "n8n-nodes-base.set", type: "n8n-nodes-base.set",
typeVersion: 3.4, typeVersion: 3,
position: [700, 200], position: [700, 300],
}; };
const evalCheckIfTestNode = { const supabaseInsertNode = {
parameters: { parameters: {
conditions: { operation: "insert",
options: { table: "scores",
caseSensitive: true,
leftValue: "",
typeValidation: "loose"
},
combinator: "and",
conditions: [
{
id: "is-test-check",
operator: {
name: "filter.operator.equals",
type: "boolean",
operation: "equals"
},
leftValue: "={{ $('Webhook Trigger').item.json.body.isTest }}",
rightValue: true
}
]
}
},
id: "eval-check-if-test",
name: "Check If Test",
type: "n8n-nodes-base.if",
typeVersion: 2.2,
position: [900, 200],
};
const evalSupabaseInsertNode = {
parameters: {
operation: "create",
tableId: "scores",
dataToSend: "autoMapInputData",
options: {}, options: {},
}, },
id: "eval-supabase-insert", id: "supabase-insert",
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: [1100, 320], position: [900, 300],
credentials: { credentials: {
supabaseApi: { supabaseApi: {
id: supabaseCredId, id: supabaseCredId,
@ -421,185 +353,28 @@ async function main() {
}, },
}; };
const evalRespondWebhookNode = { const respondWebhookNode = {
parameters: { parameters: {
options: {}, options: {},
}, },
id: "eval-respond-webhook", id: "respond-webhook",
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: [1300, 200], position: [1100, 300],
}; };
// BRANCH B: Candidate Profiling Webhook Branch (isolated vacuum extraction)
const profileCandidateWebhookNode = {
parameters: {
httpMethod: "POST",
path: "extract-candidate-profile",
responseMode: "responseNode",
options: {},
},
id: "cand-webhook-trigger",
name: "Webhook Trigger - Candidate",
type: "n8n-nodes-base.webhook",
typeVersion: 1.1,
position: [100, 600],
};
const profileCandidateChainNode = {
parameters: {
promptType: "define",
hasOutputParser: true,
needsFallback: configureFallback,
enableFallbackModel: configureFallback,
hasFallbackModel: configureFallback,
text: "={{ $('Webhook Trigger - Candidate').item.json.body.text }}",
systemMessage: "You are an AI recruitment assistant. Analyze the candidate's CV text. Extract the candidate's actual name, email, phone, a list of professional skills/technologies (buzzwords in lowercase), and a brief summary. You MUST respond with a raw JSON object containing exactly these five keys:\n- candidateName: actual name of candidate\n- email: contact email\n- phone: contact phone\n- skills: array of lowercase skill strings\n- summary: brief summary\n\nDo not include markdown code blocks or any text outside the JSON.",
},
id: "cand-llm-chain",
name: "LLM Chain Profiling (Candidate)",
type: "@n8n/n8n-nodes-langchain.chainLlm",
typeVersion: 1.9,
position: [400, 600],
};
const profileCandidatePrimaryModelNode = {
parameters: primaryConfig.nodeParameters,
id: "cand-primary-model",
name: "Primary Chat Model - Candidate",
type: primaryConfig.nodeType,
typeVersion: 1,
position: [300, 780],
credentials: {
[primaryConfig.credentialType]: {
id: primaryCredId,
name: `Semillero2_Primary_${primaryConfig.credentialType}`,
},
},
};
const profileCandidateJsonParserNode = {
parameters: {
jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"candidateName\": { \"type\": \"string\" },\n \"email\": { \"type\": \"string\" },\n \"phone\": { \"type\": \"string\" },\n \"skills\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"summary\": { \"type\": \"string\" }\n },\n \"required\": [\"candidateName\", \"email\", \"phone\", \"skills\", \"summary\"]\n}",
},
id: "cand-json-parser",
name: "Structured Output Parser - Candidate",
type: "@n8n/n8n-nodes-langchain.outputParserStructured",
typeVersion: 1,
position: [430, 780],
};
const profileCandidateRespondWebhookNode = {
parameters: {
options: {},
},
id: "cand-respond-webhook",
name: "Respond to Webhook - Candidate",
type: "n8n-nodes-base.respondToWebhook",
typeVersion: 1.1,
position: [700, 600],
};
// BRANCH C: Job Description Profiling Webhook Branch (isolated vacuum extraction)
const profileJobWebhookNode = {
parameters: {
httpMethod: "POST",
path: "extract-job-profile",
responseMode: "responseNode",
options: {},
},
id: "job-webhook-trigger",
name: "Webhook Trigger - Job",
type: "n8n-nodes-base.webhook",
typeVersion: 1.1,
position: [100, 1000],
};
const profileJobChainNode = {
parameters: {
promptType: "define",
hasOutputParser: true,
needsFallback: configureFallback,
enableFallbackModel: configureFallback,
hasFallbackModel: configureFallback,
text: "={{ $('Webhook Trigger - Job').item.json.body.requirements }}",
systemMessage: "You are an AI recruitment assistant. Analyze the job description/requirements. Extract the key skills/technologies/abilities required (as lowercase buzzwords) and a brief summary of the vacancy. You MUST respond with a raw JSON object containing exactly these two keys:\n- skills: array of lowercase skill strings\n- summary: brief summary\n\nDo not include markdown code blocks or any text outside the JSON.",
},
id: "job-llm-chain",
name: "LLM Chain Profiling (Job)",
type: "@n8n/n8n-nodes-langchain.chainLlm",
typeVersion: 1.9,
position: [400, 1000],
};
const profileJobPrimaryModelNode = {
parameters: primaryConfig.nodeParameters,
id: "job-primary-model",
name: "Primary Chat Model - Job",
type: primaryConfig.nodeType,
typeVersion: 1,
position: [300, 1180],
credentials: {
[primaryConfig.credentialType]: {
id: primaryCredId,
name: `Semillero2_Primary_${primaryConfig.credentialType}`,
},
},
};
const profileJobJsonParserNode = {
parameters: {
jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"skills\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" }\n },\n \"summary\": { \"type\": \"string\" }\n },\n \"required\": [\"skills\", \"summary\"]\n}",
},
id: "job-json-parser",
name: "Structured Output Parser - Job",
type: "@n8n/n8n-nodes-langchain.outputParserStructured",
typeVersion: 1,
position: [430, 1180],
};
const profileJobRespondWebhookNode = {
parameters: {
options: {},
},
id: "job-respond-webhook",
name: "Respond to Webhook - Job",
type: "n8n-nodes-base.respondToWebhook",
typeVersion: 1.1,
position: [700, 1000],
};
// Base nodes array
const wNodes: any[] = [ const wNodes: any[] = [
evalWebhookNode, webhookTriggerNode,
evalChainNode, primaryChainNode,
evalPrimaryModelNode, primaryModelNode,
evalJsonParserNode, jsonParserNode,
evalSetNode, setNode,
evalCheckIfTestNode, supabaseInsertNode,
evalSupabaseInsertNode, respondWebhookNode,
evalRespondWebhookNode,
profileCandidateWebhookNode,
profileCandidateChainNode,
profileCandidatePrimaryModelNode,
profileCandidateJsonParserNode,
profileCandidateRespondWebhookNode,
profileJobWebhookNode,
profileJobChainNode,
profileJobPrimaryModelNode,
profileJobJsonParserNode,
profileJobRespondWebhookNode
]; ];
// Base connections map
const wConnections: any = { const wConnections: any = {
// BRANCH A
"Webhook Trigger": { "Webhook Trigger": {
main: [ main: [
[ [
@ -627,7 +402,7 @@ async function main() {
[ [
{ {
node: "LLM Chain Evaluation (Primary)", node: "LLM Chain Evaluation (Primary)",
type: "ai_outputParser", type: "outputParser",
index: 0, index: 0,
}, },
], ],
@ -646,24 +421,6 @@ async function main() {
}, },
"Format Evaluation Data": { "Format Evaluation Data": {
main: [ main: [
[
{
node: "Check If Test",
type: "main",
index: 0,
},
],
],
},
"Check If Test": {
main: [
[
{
node: "Respond to Webhook",
type: "main",
index: 0,
},
],
[ [
{ {
node: "Insert Score to Supabase", node: "Insert Score to Supabase",
@ -684,111 +441,18 @@ async function main() {
], ],
], ],
}, },
// BRANCH B
"Webhook Trigger - Candidate": {
main: [
[
{
node: "LLM Chain Profiling (Candidate)",
type: "main",
index: 0,
},
],
],
},
"Primary Chat Model - Candidate": {
ai_languageModel: [
[
{
node: "LLM Chain Profiling (Candidate)",
type: "ai_languageModel",
index: 0,
},
],
],
},
"Structured Output Parser - Candidate": {
outputParser: [
[
{
node: "LLM Chain Profiling (Candidate)",
type: "ai_outputParser",
index: 0,
},
],
],
},
"LLM Chain Profiling (Candidate)": {
main: [
[
{
node: "Respond to Webhook - Candidate",
type: "main",
index: 0,
},
],
],
},
// BRANCH C
"Webhook Trigger - Job": {
main: [
[
{
node: "LLM Chain Profiling (Job)",
type: "main",
index: 0,
},
],
],
},
"Primary Chat Model - Job": {
ai_languageModel: [
[
{
node: "LLM Chain Profiling (Job)",
type: "ai_languageModel",
index: 0,
},
],
],
},
"Structured Output Parser - Job": {
outputParser: [
[
{
node: "LLM Chain Profiling (Job)",
type: "ai_outputParser",
index: 0,
},
],
],
},
"LLM Chain Profiling (Job)": {
main: [
[
{
node: "Respond to Webhook - Job",
type: "main",
index: 0,
},
],
],
}
}; };
// Connect Fallback Models if configured
if (configureFallback && fallbackConfig) { if (configureFallback && fallbackConfig) {
console.log("Adding Fallback Chat Models..."); console.log("Adding Fallback Chat Model...");
const evalFallbackModelNode = { const fallbackModelNode = {
parameters: fallbackConfig.nodeParameters, parameters: fallbackConfig.nodeParameters,
id: "eval-fallback-model", id: "fallback-model",
name: "Fallback Chat Model", name: "Fallback Chat Model",
type: fallbackConfig.nodeType, type: fallbackConfig.nodeType,
typeVersion: 1, typeVersion: 1,
position: [560, 380], position: [560, 480],
credentials: { credentials: {
[fallbackConfig.credentialType]: { [fallbackConfig.credentialType]: {
id: fallbackCredId, id: fallbackCredId,
@ -797,70 +461,16 @@ async function main() {
}, },
}; };
const candFallbackModelNode = { wNodes.push(fallbackModelNode);
parameters: fallbackConfig.nodeParameters,
id: "cand-fallback-model",
name: "Fallback Chat Model - Candidate",
type: fallbackConfig.nodeType,
typeVersion: 1,
position: [560, 780],
credentials: {
[fallbackConfig.credentialType]: {
id: fallbackCredId,
name: `Semillero2_Fallback_${fallbackConfig.credentialType}`,
},
},
};
const jobFallbackModelNode = { // Connect fallback model directly: source port is ai_languageModel, target port is ai_fallbackModel!
parameters: fallbackConfig.nodeParameters,
id: "job-fallback-model",
name: "Fallback Chat Model - Job",
type: fallbackConfig.nodeType,
typeVersion: 1,
position: [560, 1180],
credentials: {
[fallbackConfig.credentialType]: {
id: fallbackCredId,
name: `Semillero2_Fallback_${fallbackConfig.credentialType}`,
},
},
};
wNodes.push(evalFallbackModelNode, candFallbackModelNode, jobFallbackModelNode);
// Setup fallback connections
wConnections["Fallback Chat Model"] = { wConnections["Fallback Chat Model"] = {
ai_languageModel: [ ai_languageModel: [
[ [
{ {
node: "LLM Chain Evaluation (Primary)", node: "LLM Chain Evaluation (Primary)",
type: "ai_languageModel", type: "ai_fallbackModel",
index: 1, index: 0,
},
],
],
};
wConnections["Fallback Chat Model - Candidate"] = {
ai_languageModel: [
[
{
node: "LLM Chain Profiling (Candidate)",
type: "ai_languageModel",
index: 1,
},
],
],
};
wConnections["Fallback Chat Model - Job"] = {
ai_languageModel: [
[
{
node: "LLM Chain Profiling (Job)",
type: "ai_languageModel",
index: 1,
}, },
], ],
], ],
@ -893,7 +503,7 @@ async function main() {
console.log(`Activating workflow (ID: ${deployResult.id})...`); console.log(`Activating workflow (ID: ${deployResult.id})...`);
await n8nRequest(`/api/v1/workflows/${deployResult.id}/activate`, "POST"); await n8nRequest(`/api/v1/workflows/${deployResult.id}/activate`, "POST");
const webhookUrl = `${N8N_HOST}/webhook/evaluate-candidate`; const webhookUrl = `${N8N_HOST}/webhook/${deployResult.id}/webhook/evaluate-candidate`;
console.log("\n=============================================="); console.log("\n==============================================");
console.log("DEPLOYMENT COMPLETE"); console.log("DEPLOYMENT COMPLETE");
console.log("=============================================="); console.log("==============================================");

View file

@ -1,50 +0,0 @@
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);