From 24c88915b0a92c09b6089f0b29f0197a7cdbf84a Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Tue, 9 Jun 2026 20:33:27 -0400 Subject: [PATCH] feat: decouple ai profiling and add manual evaluation --- .../candidates/api/evaluate/route.ts | 153 +++++++ .../candidates/api/parse-cv/route.ts | 104 ++--- app/(dashboard)/candidates/page.tsx | 195 +++++---- app/(dashboard)/jobs/page.tsx | 245 +++++++++-- app/api/candidates/route.ts | 64 ++- app/api/jobs/route.ts | 8 +- lib/gemini.ts | 122 ++++++ scripts/deploy-n8n-v2.ts | 401 +++++++++++++++--- 8 files changed, 1045 insertions(+), 247 deletions(-) create mode 100644 app/(dashboard)/candidates/api/evaluate/route.ts create mode 100644 lib/gemini.ts diff --git a/app/(dashboard)/candidates/api/evaluate/route.ts b/app/(dashboard)/candidates/api/evaluate/route.ts new file mode 100644 index 0000000..7ab68e3 --- /dev/null +++ b/app/(dashboard)/candidates/api/evaluate/route.ts @@ -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 }); + } +} diff --git a/app/(dashboard)/candidates/api/parse-cv/route.ts b/app/(dashboard)/candidates/api/parse-cv/route.ts index 5ba5e73..6554449 100644 --- a/app/(dashboard)/candidates/api/parse-cv/route.ts +++ b/app/(dashboard)/candidates/api/parse-cv/route.ts @@ -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 - .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() }; - } - } else { - const errText = await n8nResponse.text(); - n8nResponseData = { error: `n8n response not ok: ${n8nResponse.status} - ${errText}` }; + // 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); } - } 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); diff --git a/app/(dashboard)/candidates/page.tsx b/app/(dashboard)/candidates/page.tsx index 78996e7..0425db5 100644 --- a/app/(dashboard)/candidates/page.tsx +++ b/app/(dashboard)/candidates/page.tsx @@ -21,6 +21,8 @@ interface Candidate { contact_info: { email: string; phone: string; + skills?: string[]; + summary?: string; }; scores?: Score[]; created_at: string; @@ -29,39 +31,109 @@ interface Candidate { export default function CandidatesPage() { const [candidates, setCandidates] = useState([]); const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadSuccess, setUploadSuccess] = useState(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); - } + setCandidates(data); + setLoading(false); }) .catch((err) => { console.error(err); - if (active) { - setLoading(false); - } + setLoading(false); + }); + }; + + useEffect(() => { + fetchCandidates(); + }, []); + + // Upload PDF CV in a vacuum + const handleFileUpload = async (e: React.ChangeEvent) => { + 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 (
-
-

Candidates

-

- A list of all candidates parsed and analyzed by the AI recruitment pipeline. +

+
+

Candidates

+

+ A list of all candidates parsed and analyzed by the AI recruitment pipeline. +

+
+
+ + {/* CV Uploader (Vacuum Ingestion) */} +
+

+ Ingest Candidate CV (in a Vacuum) +

+

+ Upload a candidate CV PDF to parse contact info, skills, and summary. + No job position will be associated initially, keeping the data isolated.

+ + {uploadError && ( +

+ {uploadError} +

+ )} + {uploadSuccess && ( +

+ {uploadSuccess} +

+ )}
{loading ? ( @@ -71,18 +143,12 @@ export default function CandidatesPage() { ) : candidates.length === 0 ? (

- No candidates found. Upload CVs on the Jobs tab to parse them. + No candidates found. Upload a CV above to get started.

) : (
{candidates.map((candidate) => { - // Get the latest score - const latestScore = - candidate.scores && candidate.scores.length > 0 - ? candidate.scores[0] - : null; - return (
Email:{" "} - + {candidate.contact_info.email} - | Phone:{" "} {candidate.contact_info.phone}
- {latestScore ? ( -
-
- - AI Assessment - - - {latestScore.ai_score} / 10 - -
-
- {latestScore.evaluation.classification} -
+
+ + {/* Extracted Profile (Summary & Skills) */} +
+ {candidate.contact_info.summary && ( +
+ + Professional Summary (Extracted) + +

+ {candidate.contact_info.summary} +

- ) : ( -
- Pending Evaluation + )} + {candidate.contact_info.skills && candidate.contact_info.skills.length > 0 && ( +
+ + Skills & Technologies + +
+ {candidate.contact_info.skills.map((skill) => ( + + {skill} + + ))} +
)}
- - {/* Score details if available */} - {latestScore ? ( -
-
- - AI Summary - -

- {latestScore.evaluation.summary} -

-
- Risk Level:{" "} - - {latestScore.evaluation.riskLevel} - -
-
-
- - Action Items / Suggestions - -

- {latestScore.evaluation.suggestions} -

-
-
- ) : ( -

- This candidate's CV has been indexed, but the AI evaluation - has not yet completed. The background n8n workflow updates - scores upon completion. -

- )}
); })} diff --git a/app/(dashboard)/jobs/page.tsx b/app/(dashboard)/jobs/page.tsx index 2a2a35a..e9ea4aa 100644 --- a/app/(dashboard)/jobs/page.tsx +++ b/app/(dashboard)/jobs/page.tsx @@ -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(null); const [uploadSuccess, setUploadSuccess] = useState(null); + // Evaluation states + const [evaluatingIds, setEvaluatingIds] = useState>({}); + // 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 (
{/* Left Column: Create Form & Vacancies List */} @@ -292,23 +330,42 @@ export default function JobsPage() {

- {/* Requirements */} -
-

- Requirements -

-

- {selectedJob.requirements.text} -

+ {/* Requirements & Extracted Job Skills */} +
+
+

+ Description +

+

+ {selectedJob.requirements.text} +

+
+ {selectedJob.requirements.skills && selectedJob.requirements.skills.length > 0 && ( +
+

+ Extracted Job Keywords / Required Skills +

+
+ {selectedJob.requirements.skills.map((skill) => ( + + {skill} + + ))} +
+
+ )}
{/* PDF Uploader */}

- Upload Candidate CV (PDF) + Upload Candidate CV for this Vacancy (PDF)

- 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.