From 529958f4924ca2ea51b4ae02685a551404db4752 Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Wed, 10 Jun 2026 08:46:36 -0400 Subject: [PATCH] feat: scale up candidate evaluation and promotion --- .gitignore | 3 + .../candidates/api/evaluate/route.ts | 46 +- .../candidates/api/parse-cv/route.ts | 18 +- .../candidates/api/promote/route.ts | 67 +++ app/(dashboard)/jobs/page.tsx | 566 +++++++++++++----- app/api/candidates/route.ts | 31 +- scripts/deploy-n8n-v2.ts | 4 +- .../20260610000000_add_job_id_to_scores.sql | 2 + 8 files changed, 516 insertions(+), 221 deletions(-) create mode 100644 app/(dashboard)/candidates/api/promote/route.ts create mode 100644 supabase/migrations/20260610000000_add_job_id_to_scores.sql diff --git a/.gitignore b/.gitignore index 7b8da95..e88fad2 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# agents config +.agents/ diff --git a/app/(dashboard)/candidates/api/evaluate/route.ts b/app/(dashboard)/candidates/api/evaluate/route.ts index 7ab68e3..6b9f523 100644 --- a/app/(dashboard)/candidates/api/evaluate/route.ts +++ b/app/(dashboard)/candidates/api/evaluate/route.ts @@ -57,47 +57,7 @@ export async function POST(request: NextRequest) { 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 + // 3. Call n8n webhook passing jobId instead of interviewId let n8nResponseData = null; const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL; @@ -110,7 +70,7 @@ export async function POST(request: NextRequest) { }, body: JSON.stringify({ candidateId, - interviewId, + jobId, candidateName, candidateEmail: email, text: cvText, @@ -141,7 +101,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, candidateId, - interviewId, + jobId, candidateName, n8nResponse: n8nResponseData, }); diff --git a/app/(dashboard)/candidates/api/parse-cv/route.ts b/app/(dashboard)/candidates/api/parse-cv/route.ts index 6554449..1d41da1 100644 --- a/app/(dashboard)/candidates/api/parse-cv/route.ts +++ b/app/(dashboard)/candidates/api/parse-cv/route.ts @@ -8,8 +8,6 @@ export async function POST(request: NextRequest) { try { const formData = await request.formData(); const file = formData.get("file") as File | null; - const jobId = formData.get("jobId") as string | null; - if (!file) { return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); } @@ -72,20 +70,8 @@ export async function POST(request: NextRequest) { 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", - }); - if (interviewError) { - console.error("Failed to insert interview:", interviewError.message); - } - } + // Decoupled: We no longer create an initial interview record on upload. + // Interviews are only queued when recruiter manually takes action. } return NextResponse.json({ diff --git a/app/(dashboard)/candidates/api/promote/route.ts b/app/(dashboard)/candidates/api/promote/route.ts new file mode 100644 index 0000000..2c94844 --- /dev/null +++ b/app/(dashboard)/candidates/api/promote/route.ts @@ -0,0 +1,67 @@ +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. Check if interview record already exists + const { data: existingInterviews, error: fetchError } = await supabase + .from("interviews") + .select("id") + .eq("candidate_id", candidateId) + .eq("job_id", jobId) + .limit(1); + + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }); + } + + if (existingInterviews && existingInterviews.length > 0) { + return NextResponse.json({ + success: true, + message: "Candidate already promoted to interviews.", + interviewId: existingInterviews[0].id, + }); + } + + // 2. Insert new interview record + const { data: newInterview, error: insertError } = await supabase + .from("interviews") + .insert({ + candidate_id: candidateId, + job_id: jobId, + interview_date: new Date().toISOString(), + stage: "Screening", + }) + .select("id") + .single(); + + if (insertError || !newInterview) { + return NextResponse.json( + { error: insertError?.message || "Failed to create interview record" }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + message: "Candidate successfully promoted to interviews.", + interviewId: newInterview.id, + }); + } catch (error: unknown) { + console.error("Error in promote API:", error); + const errorMessage = error instanceof Error ? error.message : "Internal server error"; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/(dashboard)/jobs/page.tsx b/app/(dashboard)/jobs/page.tsx index e9ea4aa..f026b95 100644 --- a/app/(dashboard)/jobs/page.tsx +++ b/app/(dashboard)/jobs/page.tsx @@ -36,9 +36,39 @@ interface Candidate { }; similarity?: number; scores?: Score[]; + interview?: { + id: string; + stage: string; + interview_date: string; + feedback: string | null; + } | null; created_at: string; } +interface SkillsOverlap { + matchedSkills: string[]; + missingSkills: string[]; + overlapCount: number; + totalRequired: number; + matchPct: number; + isPotentialMatch: boolean; +} + +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; +}; + export default function JobsPage() { const [jobs, setJobs] = useState([]); const [selectedJob, setSelectedJob] = useState(null); @@ -52,6 +82,14 @@ export default function JobsPage() { // Evaluation states const [evaluatingIds, setEvaluatingIds] = useState>({}); + const [isBulkEvaluating, setIsBulkEvaluating] = useState(false); + const [bulkEvalProgress, setBulkEvalProgress] = useState(""); + + // Promotion states + const [promotingIds, setPromotingIds] = useState>({}); + + // Display states + const [showHiddenCandidates, setShowHiddenCandidates] = useState(false); // Form states const [newTitle, setNewTitle] = useState(""); @@ -232,6 +270,101 @@ export default function JobsPage() { } }; + // Bulk evaluate visible matches without scores sequentially + const handleBulkEvaluate = async () => { + if (!selectedJob) return; + + // Find all visible candidates without scores + const candidatesToEval = matches.filter((match) => { + const jobSkills = selectedJob.requirements.skills || []; + const candidateSkills = match.contact_info.skills || []; + const matchedSkills = jobSkills.filter((js) => + candidateSkills.some((cs) => skillsMatch(cs, js)) + ); + const matchPct = jobSkills.length > 0 ? Math.round((matchedSkills.length / jobSkills.length) * 100) : 0; + const isPotentialMatch = matchPct >= 75; + const latestScore = match.scores?.[0]; + return isPotentialMatch && !latestScore; + }); + + if (candidatesToEval.length === 0) { + alert("No candidates to evaluate."); + return; + } + + try { + setIsBulkEvaluating(true); + for (let i = 0; i < candidatesToEval.length; i++) { + const candidate = candidatesToEval[i]; + setBulkEvalProgress(`Evaluating ${i + 1} of ${candidatesToEval.length} (${candidate.name})...`); + + const res = await fetch("/candidates/api/evaluate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ candidateId: candidate.id, jobId: selectedJob.id }), + }); + + if (!res.ok) { + console.error(`Failed to evaluate ${candidate.name}`); + } + } + + setBulkEvalProgress("All evaluations completed!"); + setTimeout(() => setBulkEvalProgress(""), 3000); + + // 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 bulk evaluating candidates"); + } finally { + setIsBulkEvaluating(false); + } + }; + + // Promote candidate to interviews + const handlePromote = async (candidateId: string) => { + if (!selectedJob) return; + try { + setPromotingIds((prev) => ({ ...prev, [candidateId]: true })); + const res = await fetch("/candidates/api/promote", { + 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 promote candidate"); + } + + const resData = await res.json(); + + // Update local state to reflect that the candidate is now promoted + setMatches((prev) => + prev.map((match) => + match.id === candidateId + ? { + ...match, + interview: { + id: resData.interviewId, + stage: "Screening", + interview_date: new Date().toISOString(), + feedback: null, + }, + } + : match + ) + ); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Error promoting candidate"); + } finally { + setPromotingIds((prev) => ({ ...prev, [candidateId]: false })); + } + }; + return (
{/* Left Column: Create Form & Vacancies List */} @@ -391,173 +524,292 @@ export default function JobsPage() { {/* Matches List */}
-

- Candidates & Compatibility Index -

- {loadingMatches ? ( -

Finding matches...

- ) : matches.length === 0 ? ( -

- No candidates have been uploaded or matched yet. -

- ) : ( -
- {matches.map((match) => { - const similarityPct = match.similarity - ? Math.round(match.similarity * 100) - : null; - const latestScore = match.scores?.[0]; + {/* Computed lists */} + {(() => { + const getSkillsOverlap = (match: Candidate) => { + const jobSkills = selectedJob.requirements.skills || []; + const candidateSkills = match.contact_info.skills || []; + 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; - // Programmatic skills matching logic - const jobSkills = selectedJob.requirements.skills || []; - const candidateSkills = match.contact_info.skills || []; + return { + matchedSkills, + missingSkills, + overlapCount, + totalRequired, + matchPct, + isPotentialMatch, + }; + }; - 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; + const visibleMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = []; + const hiddenMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = []; - return ( -
- {/* Upper info panel */} -
-
-
- {match.name} -
-
- Email: {match.contact_info.email} - Phone: {match.contact_info.phone} -
+ matches.forEach((match) => { + const overlap = getSkillsOverlap(match); + const latestScore = match.scores?.[0]; + const isUnqualified = latestScore?.evaluation.classification === "Unqualified"; + + if (!overlap.isPotentialMatch || isUnqualified) { + hiddenMatches.push({ candidate: match, overlap }); + } else { + visibleMatches.push({ candidate: match, overlap }); + } + }); + + const visibleMatchesToEval = visibleMatches.filter( + ({ candidate }) => !candidate.scores?.[0] + ); + + const renderCandidateCard = (match: Candidate, overlap: SkillsOverlap) => { + const similarityPct = match.similarity + ? Math.round(match.similarity * 100) + : null; + const latestScore = match.scores?.[0]; + const { matchedSkills, missingSkills, overlapCount, totalRequired, matchPct, isPotentialMatch } = overlap; + const isUnqualified = latestScore?.evaluation.classification === "Unqualified"; + const jobSkills = selectedJob.requirements.skills || []; + + return ( +
+ {/* Upper info panel */} +
+
+
+ {match.name}
- -
- {/* Pre-selection status badge */} - - {isPotentialMatch - ? `Potential Match (${matchPct}% overlap)` - : `Skill Mismatch (${matchPct}% overlap)`} - - - {/* Semantic embedding similarity badge */} - {similarityPct !== null && ( - - Semantic: {similarityPct}% - - )} +
+ Email: {match.contact_info.email} + Phone: {match.contact_info.phone}
- {/* Skills overlap details */} -
-
- Skills Check: {overlapCount} of {totalRequired} matching -
+
+ {/* Pre-selection status badge */} + + {isPotentialMatch + ? `Potential Match (${matchPct}% overlap)` + : `Skill Mismatch (${matchPct}% overlap)`} + -
- {/* Display matched skills in green */} - {matchedSkills.map(skill => ( - - {skill} - - ))} - - {/* Display missing skills in light red/gray dashed */} - {missingSkills.map(skill => ( - - {skill} (missing) - - ))} - - {/* Fallback if no skills are loaded */} - {jobSkills.length === 0 && ( - - No required skills extracted for this job yet. - - )} -
-
- - {/* Bottom evaluation / action panel */} -
-
- {latestScore ? ( -
-
- AI ASSESSMENT RESULT -
-
- Decision: {latestScore.evaluation.classification} - | - Score: {latestScore.ai_score} / 100 -
-
- {latestScore.evaluation.summary} -
-
- ) : ( -
- Ready for deep assessment. Only potential matches recommended for LLM budget optimization. -
- )} -
- -
- -
+ {/* Semantic embedding similarity badge */} + {similarityPct !== null && ( + + Semantic: {similarityPct}% + + )}
- ); - })} -
- )} + + {/* Skills overlap details */} +
+
+ Skills Check: {overlapCount} of {totalRequired} matching +
+ +
+ {/* Display matched skills in green */} + {matchedSkills.map((skill: string) => ( + + {skill} + + ))} + + {/* Display missing skills in light red/gray dashed */} + {missingSkills.map((skill: string) => ( + + {skill} (missing) + + ))} + + {/* Fallback if no skills are loaded */} + {jobSkills.length === 0 && ( + + No required skills extracted for this job yet. + + )} +
+
+ + {/* Bottom evaluation / action panel */} +
+
+ {latestScore ? ( +
+
+ AI ASSESSMENT RESULT +
+
+ Decision: {latestScore.evaluation.classification} + | + Score: {latestScore.ai_score} / 100 +
+
+ {latestScore.evaluation.summary} +
+
+ ) : ( +
+ Ready for deep assessment. Only potential matches recommended for LLM budget optimization. +
+ )} +
+ +
+ {/* Run/Re-run AI evaluation */} + + + {/* Promote to Interview Pipeline */} + {match.interview ? ( + + Promoted ({match.interview.stage}) + + ) : ( + + )} +
+
+
+ ); + }; + + return ( +
+ {/* Toolbar / Header */} +
+
+ + Showing {visibleMatches.length} qualified matches + + {visibleMatchesToEval.length > 0 && ( + + ({visibleMatchesToEval.length} unevaluated) + + )} +
+ + {visibleMatchesToEval.length > 0 && ( + + )} +
+ + {/* Visible Matches List */} + {loadingMatches ? ( +

Finding matches...

+ ) : visibleMatches.length === 0 && !loadingMatches ? ( +
+

No active potential matches found.

+

Upload CVs or check the mismatch/unqualified list below.

+
+ ) : ( +
+ {visibleMatches.map(({ candidate, overlap }) => + renderCandidateCard(candidate, overlap) + )} +
+ )} + + {/* Expandable Hidden Matches List */} + {hiddenMatches.length > 0 && ( +
+ + + {showHiddenCandidates && ( +
+ {hiddenMatches.map(({ candidate, overlap }) => + renderCandidateCard(candidate, overlap) + )} +
+ )} +
+ )} +
+ ); + })()}
) : ( diff --git a/app/api/candidates/route.ts b/app/api/candidates/route.ts index ef3e5f2..25e02ba 100644 --- a/app/api/candidates/route.ts +++ b/app/api/candidates/route.ts @@ -14,15 +14,25 @@ interface CandidateScore { created_at: string; } +interface InterviewDetail { + id: string; + stage: string; + interview_date: string; + feedback: string | null; +} + interface RankedCandidate { id: string; name: string; contact_info: { email: string; phone: string; + skills?: string[]; + summary?: string; }; similarity?: number; scores?: CandidateScore[]; + interview?: InterviewDetail | null; } export async function GET(request: NextRequest) { @@ -62,16 +72,29 @@ export async function GET(request: NextRequest) { const candidatesList = (rankedCandidates as unknown as RankedCandidate[]) || []; - // 3. Fetch scores for these matched candidates to return AI scores/details + // 3. Fetch scores and interviews for these matched candidates if (candidatesList.length > 0) { const candidateIds = candidatesList.map((c) => c.id); const { data: scores, error: scoresError } = await supabase .from("scores") - .select("*, interviews!inner(job_id)") + .select("*") .in("candidate_id", candidateIds) - .eq("interviews.job_id", jobId) + .eq("job_id", jobId) .order("created_at", { ascending: false }); + const { data: interviews, error: interviewsError } = await supabase + .from("interviews") + .select("*") + .in("candidate_id", candidateIds) + .eq("job_id", jobId); + + const interviewsMap = new Map(); + if (!interviewsError && interviews) { + interviews.forEach((i) => { + interviewsMap.set(i.candidate_id, i as unknown as InterviewDetail); + }); + } + if (!scoresError && scores) { const typedScores = (scores as unknown as CandidateScore[]) || []; @@ -109,10 +132,12 @@ export async function GET(request: NextRequest) { candidatesList.forEach((c) => { c.scores = scoresMap.get(c.id) || []; + c.interview = interviewsMap.get(c.id) || null; }); } else { candidatesList.forEach((c) => { c.scores = []; + c.interview = interviewsMap.get(c.id) || null; }); } } diff --git a/scripts/deploy-n8n-v2.ts b/scripts/deploy-n8n-v2.ts index a323cf8..8135007 100644 --- a/scripts/deploy-n8n-v2.ts +++ b/scripts/deploy-n8n-v2.ts @@ -345,8 +345,8 @@ async function main() { type: "string", }, { - name: "interview_id", - value: "={{ $('Webhook Trigger').item.json.body.interviewId }}", + name: "job_id", + value: "={{ $('Webhook Trigger').item.json.body.jobId }}", type: "string", }, { diff --git a/supabase/migrations/20260610000000_add_job_id_to_scores.sql b/supabase/migrations/20260610000000_add_job_id_to_scores.sql new file mode 100644 index 0000000..f7522ff --- /dev/null +++ b/supabase/migrations/20260610000000_add_job_id_to_scores.sql @@ -0,0 +1,2 @@ +-- Add job_id to scores table to decouple AI evaluations from interviews +ALTER TABLE scores ADD COLUMN IF NOT EXISTS job_id UUID REFERENCES jobs(id) ON DELETE CASCADE;