feat: decouple ai profiling and add manual evaluation

This commit is contained in:
Gabriel Ramos 2026-06-09 20:33:27 -04:00
parent 984b8b00fd
commit 24c88915b0
8 changed files with 1045 additions and 247 deletions

View file

@ -0,0 +1,153 @@
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,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase";
import { generateEmbedding } from "@/lib/embeddings";
import { PDFParse } from "pdf-parse";
import { extractCandidateProfile } from "@/lib/gemini";
export async function POST(request: NextRequest) {
try {
@ -13,10 +14,6 @@ export async function POST(request: NextRequest) {
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 buffer = Buffer.from(arrayBuffer);
@ -33,17 +30,8 @@ export async function POST(request: NextRequest) {
// Clean text
const cleanText = text.replace(/\s+/g, " ").trim();
// Extract name from file (strip extension)
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";
// Extract professional profile using Gemini 1.5 Flash
const profile = await extractCandidateProfile(cleanText);
// Generate candidate embedding
const embedding = await generateEmbedding(cleanText);
@ -51,19 +39,24 @@ export async function POST(request: NextRequest) {
const isTest = formData.get("isTest") === "true";
let candidateId = "00000000-0000-0000-0000-000000000000";
let interviewId = "00000000-0000-0000-0000-000000000000";
let candidateName = name;
let candidateName = profile.candidateName;
if (!isTest) {
// Initialize Supabase admin client
const supabase = createServerSupabaseClient();
// Insert candidate
// Insert candidate with extracted details (including skills, summary, and cv_text)
const { data: candidate, error: candidateError } = await supabase
.from("candidates")
.insert({
name,
contact_info: { email, phone },
name: profile.candidateName,
contact_info: {
email: profile.email,
phone: profile.phone,
skills: profile.skills || [],
summary: profile.summary || "",
cv_text: cleanText,
},
embedding,
})
.select("*")
@ -76,75 +69,30 @@ export async function POST(request: NextRequest) {
);
}
// Insert an initial interview
const { data: interview, error: interviewError } = await supabase
candidateId = candidate.id;
candidateName = candidate.name;
// 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")
.insert({
candidate_id: candidate.id,
job_id: jobId,
interview_date: new Date().toISOString(),
stage: "Screening",
})
.select("*")
.single();
if (interviewError || !interview) {
return NextResponse.json(
{ error: interviewError?.message || "Failed to insert interview" },
{ status: 500 }
);
}
candidateId = candidate.id;
interviewId = interview.id;
candidateName = candidate.name;
}
// 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: cleanText,
isTest,
}),
});
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() };
if (interviewError) {
console.error("Failed to insert interview:", interviewError.message);
}
} 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({
success: true,
candidateId,
interviewId,
candidateName,
n8nResponse: n8nResponseData,
profile,
});
} catch (error: unknown) {
console.error("Error in parse-cv route:", error);

View file

@ -21,6 +21,8 @@ interface Candidate {
contact_info: {
email: string;
phone: string;
skills?: string[];
summary?: string;
};
scores?: Score[];
created_at: string;
@ -29,40 +31,110 @@ interface Candidate {
export default function CandidatesPage() {
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
useEffect(() => {
let active = true;
const fetchCandidates = () => {
fetch("/api/candidates")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch candidates");
return res.json();
})
.then((data) => {
if (active) {
setCandidates(data);
setLoading(false);
}
})
.catch((err) => {
console.error(err);
if (active) {
setLoading(false);
});
};
useEffect(() => {
fetchCandidates();
}, []);
// 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,
});
return () => {
active = false;
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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900">Candidates</h1>
<p className="text-slate-600 text-sm">
A list of all candidates parsed and analyzed by the AI recruitment pipeline.
</p>
</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 ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
@ -71,18 +143,12 @@ export default function CandidatesPage() {
) : candidates.length === 0 ? (
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
<p className="text-slate-500 text-sm">
No candidates found. Upload CVs on the Jobs tab to parse them.
No candidates found. Upload a CV above to get started.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6">
{candidates.map((candidate) => {
// Get the latest score
const latestScore =
candidate.scores && candidate.scores.length > 0
? candidate.scores[0]
: null;
return (
<div
key={candidate.id}
@ -96,70 +162,47 @@ export default function CandidatesPage() {
</h2>
<div className="text-xs text-slate-500 mt-1">
Email:{" "}
<span className="text-slate-600 font-medium">
<span className="text-slate-600 font-medium mr-3">
{candidate.contact_info.email}
</span>
<span className="mx-2">|</span>
Phone:{" "}
<span className="text-slate-600 font-medium">
{candidate.contact_info.phone}
</span>
</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>
{/* Score details if available */}
{latestScore ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
AI Summary
{/* Extracted Profile (Summary & Skills) */}
<div className="flex flex-col gap-2">
{candidate.contact_info.summary && (
<div>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider block mb-1">
Professional Summary (Extracted)
</span>
<p className="text-slate-600 leading-relaxed">
{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 className="text-slate-600 text-sm leading-relaxed">
{candidate.contact_info.summary}
</p>
</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>
);
})}

View file

@ -5,7 +5,11 @@ import React, { useState, useEffect } from "react";
interface Job {
id: string;
title: string;
requirements: { text: string };
requirements: {
text: string;
skills?: string[];
summary?: string;
};
created_at: string;
}
@ -27,6 +31,8 @@ interface Candidate {
contact_info: {
email: string;
phone: string;
skills?: string[];
summary?: string;
};
similarity?: number;
scores?: Score[];
@ -44,6 +50,9 @@ export default function JobsPage() {
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
// Evaluation states
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
// Form states
const [newTitle, setNewTitle] = useState("");
const [newRequirements, setNewRequirements] = useState("");
@ -177,7 +186,8 @@ export default function JobsPage() {
throw new Error(errData.error || "Failed to process CV");
}
setUploadSuccess(`CV for ${file.name} successfully parsed and indexed!`);
const resData = await res.json();
setUploadSuccess(`CV for ${resData.candidateName || file.name} successfully parsed and linked!`);
// Refresh matches for current job
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`);
@ -194,6 +204,34 @@ 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 (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Create Form & Vacancies List */}
@ -292,23 +330,42 @@ export default function JobsPage() {
</p>
</div>
{/* Requirements */}
<div className="p-4 bg-slate-50 rounded-md border border-slate-200">
<h3 className="text-sm font-semibold text-slate-900 mb-2">
Requirements
{/* Requirements & Extracted Job Skills */}
<div className="p-4 bg-slate-50 rounded-md border border-slate-200 flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold text-slate-900 mb-1">
Description
</h3>
<p className="text-slate-600 text-sm whitespace-pre-wrap">
<p className="text-slate-600 text-sm whitespace-pre-wrap leading-relaxed">
{selectedJob.requirements.text}
</p>
</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 */}
<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">
Upload Candidate CV (PDF)
Upload Candidate CV for this Vacancy (PDF)
</h3>
<p className="text-xs text-slate-500 mb-4 max-w-md">
Uploading a candidate CV parses the text, calculates its semantic matching score, schedules a screening interview, and signals n8n workflow.
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.
</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..." : "Choose CV File"}
@ -335,7 +392,7 @@ export default function JobsPage() {
{/* Matches List */}
<div>
<h3 className="text-base font-bold text-slate-900 mb-3">
Matched Candidates (Semantic Similarity)
Candidates & Compatibility Index
</h3>
{loadingMatches ? (
<p className="text-slate-500 text-sm">Finding matches...</p>
@ -344,46 +401,158 @@ export default function JobsPage() {
No candidates have been uploaded or matched yet.
</p>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4">
{matches.map((match) => {
const similarityPct = match.similarity
? Math.round(match.similarity * 100)
: null;
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 (
<div
key={match.id}
className="p-4 rounded-md border border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
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"
>
{/* 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="text-slate-900 font-bold text-sm">
<div className="text-slate-900 font-bold text-base">
{match.name}
</div>
<div className="text-xs text-slate-500">
Email: {match.contact_info.email} | Phone:{" "}
{match.contact_info.phone}
Email: <span className="text-slate-700 font-medium mr-3">{match.contact_info.email}</span>
Phone: <span className="text-slate-700 font-medium">{match.contact_info.phone}</span>
</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 items-center gap-4">
<div className="flex flex-wrap items-center gap-2">
{/* Pre-selection status badge */}
<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 && (
<div className="text-right">
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wider">
Match Score
<span className="px-2.5 py-1 text-xs font-semibold rounded-md border bg-blue-50 text-blue-700 border-blue-200">
Semantic: {similarityPct}%
</span>
<span className="text-lg font-bold text-blue-600">
{similarityPct}%
)}
</div>
</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>
))}
{/* 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 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>
);
})}

View file

@ -67,16 +67,43 @@ export async function GET(request: NextRequest) {
const candidateIds = candidatesList.map((c) => c.id);
const { data: scores, error: scoresError } = await supabase
.from("scores")
.select("*")
.in("candidate_id", candidateIds);
.select("*, interviews!inner(job_id)")
.in("candidate_id", candidateIds)
.eq("interviews.job_id", jobId)
.order("created_at", { ascending: false });
if (!scoresError && scores) {
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
const scoresMap = new Map<string, CandidateScore[]>();
typedScores.forEach((s) => {
const list = scoresMap.get(s.candidate_id) || [];
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);
});
@ -92,17 +119,44 @@ export async function GET(request: NextRequest) {
return NextResponse.json(candidatesList);
} else {
// Fetch all candidates sorted by created_at descending
// Fetch all candidates sorted by created_at descending, along with scores ordered descending
const { data: candidates, error } = await supabase
.from("candidates")
.select("*, scores(*)")
.order("created_at", { ascending: false });
.order("created_at", { ascending: false })
.order("created_at", { referencedTable: "scores", ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(candidates);
// Safeguard: Sort and normalize scores inside each candidate in Javascript as well
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) {
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";

View file

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

122
lib/gemini.ts Normal file
View file

@ -0,0 +1,122 @@
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

@ -277,44 +277,45 @@ async function main() {
// 3. Define workflow nodes dynamically
console.log("\nBuilding workflow nodes...");
const webhookTriggerNode = {
// BRANCH A: Candidate Evaluation Webhook Branch
const evalWebhookNode = {
parameters: {
httpMethod: "POST",
path: "evaluate-candidate",
responseMode: "responseNode",
options: {},
},
id: "webhook-trigger",
id: "eval-webhook-trigger",
name: "Webhook Trigger",
type: "n8n-nodes-base.webhook",
typeVersion: 1.1,
position: [100, 300],
position: [100, 200],
};
const primaryChainNode = {
const evalChainNode = {
parameters: {
promptType: "define", // Correct underlying value for manually defining prompt!
hasOutputParser: true, // Enforce "Require Specific Output Format"
needsFallback: configureFallback, // True underlying parameter to enable Fallback Model in v1.8+
enableFallbackModel: configureFallback, // True underlying key to enable fallback model (backward compatibility)
hasFallbackModel: configureFallback, // Secondary key for fallback model (safeguard)
text: "={{ $('Webhook Trigger').item.json.body.text }}",
systemMessage: "You are an AI recruitment assistant evaluating a candidate's CV for a job vacancy. Analyze the candidate's CV text. You MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief candidate summary (max 3 sentences).\n- classification: 'Qualified', 'Unqualified', or 'Review'.\n- suggestions: an array of recommendations for next steps (e.g. ['Schedule interview', 'Reject', 'Verify references']).\n- riskLevel: 'Low', 'Medium', or 'High'.\n- ai_score: a number between 0 and 100 representing general suitability.\n\nDo not include markdown code blocks or any text outside the JSON.",
promptType: "define",
hasOutputParser: true,
needsFallback: configureFallback,
enableFallbackModel: configureFallback,
hasFallbackModel: configureFallback,
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: "llm-chain-primary",
id: "eval-llm-chain",
name: "LLM Chain Evaluation (Primary)",
type: "@n8n/n8n-nodes-langchain.chainLlm",
typeVersion: 1.9,
position: [400, 300],
position: [400, 200],
};
const primaryModelNode = {
const evalPrimaryModelNode = {
parameters: primaryConfig.nodeParameters,
id: "primary-model",
id: "eval-primary-model",
name: "Primary Chat Model",
type: primaryConfig.nodeType,
typeVersion: 1,
position: [300, 480],
position: [300, 380],
credentials: {
[primaryConfig.credentialType]: {
id: primaryCredId,
@ -323,19 +324,18 @@ async function main() {
},
};
const jsonParserNode = {
const evalJsonParserNode = {
parameters: {
jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"summary\": {\n \"type\": \"string\"\n },\n \"classification\": {\n \"type\": \"string\",\n \"enum\": [\"Qualified\", \"Unqualified\", \"Review\"]\n },\n \"suggestions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"riskLevel\": {\n \"type\": \"string\",\n \"enum\": [\"Low\", \"Medium\", \"High\"]\n },\n \"ai_score\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\"summary\", \"classification\", \"suggestions\", \"riskLevel\", \"ai_score\"]\n}",
jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"summary\": {\n \"type\": \"string\"\n },\n \"classification\": {\n \"type\": \"string\",\n \"enum\": [\"Qualified\", \"Unqualified\", \"Review\"]\n },\n \"suggestions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"riskLevel\": {\n \"type\": \"string\",\n \"enum\": [\"Low\", \"Medium\", \"High\"]\n },\n \"ai_score\": {\n \"type\": \"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}",
},
id: "json-parser",
id: "eval-json-parser",
name: "Structured Output Parser",
type: "@n8n/n8n-nodes-langchain.outputParserStructured",
typeVersion: 1,
position: [430, 480],
position: [430, 380],
};
// Replace Code node with a native Edit Fields (Set) node to avoid code blocks
const setNode = {
const evalSetNode = {
parameters: {
assignments: {
assignments: [
@ -351,7 +351,7 @@ async function main() {
},
{
name: "ai_score",
value: "={{ $json.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)) }}",
type: "number",
},
{
@ -364,14 +364,14 @@ async function main() {
include: "none",
options: {},
},
id: "format-data",
id: "eval-format-data",
name: "Format Evaluation Data",
type: "n8n-nodes-base.set",
typeVersion: 3.4,
position: [700, 300],
position: [700, 200],
};
const checkIfTestNode = {
const evalCheckIfTestNode = {
parameters: {
conditions: {
options: {
@ -394,25 +394,25 @@ async function main() {
]
}
},
id: "check-if-test",
id: "eval-check-if-test",
name: "Check If Test",
type: "n8n-nodes-base.if",
typeVersion: 2.2,
position: [900, 300],
position: [900, 200],
};
const supabaseInsertNode = {
const evalSupabaseInsertNode = {
parameters: {
operation: "create",
tableId: "scores",
dataToSend: "autoMapInputData",
options: {},
},
id: "supabase-insert",
id: "eval-supabase-insert",
name: "Insert Score to Supabase",
type: "n8n-nodes-base.supabase",
typeVersion: 1,
position: [1100, 420],
position: [1100, 320],
credentials: {
supabaseApi: {
id: supabaseCredId,
@ -421,29 +421,185 @@ async function main() {
},
};
const respondWebhookNode = {
const evalRespondWebhookNode = {
parameters: {
options: {},
},
id: "respond-webhook",
id: "eval-respond-webhook",
name: "Respond to Webhook",
type: "n8n-nodes-base.respondToWebhook",
typeVersion: 1.1,
position: [1300, 300],
position: [1300, 200],
};
// 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[] = [
webhookTriggerNode,
primaryChainNode,
primaryModelNode,
jsonParserNode,
setNode,
checkIfTestNode,
supabaseInsertNode,
respondWebhookNode,
evalWebhookNode,
evalChainNode,
evalPrimaryModelNode,
evalJsonParserNode,
evalSetNode,
evalCheckIfTestNode,
evalSupabaseInsertNode,
evalRespondWebhookNode,
profileCandidateWebhookNode,
profileCandidateChainNode,
profileCandidatePrimaryModelNode,
profileCandidateJsonParserNode,
profileCandidateRespondWebhookNode,
profileJobWebhookNode,
profileJobChainNode,
profileJobPrimaryModelNode,
profileJobJsonParserNode,
profileJobRespondWebhookNode
];
// Base connections map
const wConnections: any = {
// BRANCH A
"Webhook Trigger": {
main: [
[
@ -471,7 +627,7 @@ async function main() {
[
{
node: "LLM Chain Evaluation (Primary)",
type: "ai_outputParser", // Correct destination port type!
type: "ai_outputParser",
index: 0,
},
],
@ -528,18 +684,111 @@ 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) {
console.log("Adding Fallback Chat Model...");
console.log("Adding Fallback Chat Models...");
const fallbackModelNode = {
const evalFallbackModelNode = {
parameters: fallbackConfig.nodeParameters,
id: "fallback-model",
id: "eval-fallback-model",
name: "Fallback Chat Model",
type: fallbackConfig.nodeType,
typeVersion: 1,
position: [560, 480],
position: [560, 380],
credentials: {
[fallbackConfig.credentialType]: {
id: fallbackCredId,
@ -548,9 +797,39 @@ async function main() {
},
};
wNodes.push(fallbackModelNode);
const candFallbackModelNode = {
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}`,
},
},
};
// Connect fallback model directly: source port is ai_languageModel, target port is ai_languageModel (index 1)!
const jobFallbackModelNode = {
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"] = {
ai_languageModel: [
[
@ -562,6 +841,30 @@ async function main() {
],
],
};
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,
},
],
],
};
}
// Define complete workflow object