feat(candidates): scale UI, bulk upload & AI diffs
This commit is contained in:
parent
aa028ec263
commit
1a439e3bd0
7 changed files with 1288 additions and 195 deletions
12
README.md
12
README.md
|
|
@ -12,3 +12,15 @@ A modern ATS platform designed to parse PDF CVs using multimodal AI, rank candid
|
||||||
- **Recruiter - Stage Automation:** As a recruiter, I want candidates to move through recruitment stages automatically.
|
- **Recruiter - Stage Automation:** As a recruiter, I want candidates to move through recruitment stages automatically.
|
||||||
- **Candidate - Automated Emails:** As a candidate, I want to receive automated email confirmations for every stage change.
|
- **Candidate - Automated Emails:** As a candidate, I want to receive automated email confirmations for every stage change.
|
||||||
- **Talent Team - Vacancy Metrics:** As a talent team, we want metrics on the progress per vacancy.
|
- **Talent Team - Vacancy Metrics:** As a talent team, we want metrics on the progress per vacancy.
|
||||||
|
|
||||||
|
## Setup & Prerequisites
|
||||||
|
|
||||||
|
### 1. Google AI Studio Account (Mandatory)
|
||||||
|
Vector embeddings matching and search operations require a direct call to the Google Gemini Embeddings API (`models/gemini-embedding-001`).
|
||||||
|
* **Prerequisite**: You must obtain a free-tier or paid-tier Gemini API key from [Google AI Studio](https://aistudio.google.com/).
|
||||||
|
* **Usage**: The embedding model is free for up to 1,500 requests per day (15 requests per minute), which covers standard development and testing requirements.
|
||||||
|
* **Configuration**: Add your key to the `.env` file at the root of the project:
|
||||||
|
```env
|
||||||
|
GEMINI_API_KEY=your_google_ai_studio_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,45 +35,151 @@ export async function POST(request: NextRequest) {
|
||||||
const embedding = await generateEmbedding(cleanText);
|
const embedding = await generateEmbedding(cleanText);
|
||||||
|
|
||||||
const isTest = formData.get("isTest") === "true";
|
const isTest = formData.get("isTest") === "true";
|
||||||
|
const duplicateAction = formData.get("duplicateAction") || "check";
|
||||||
|
|
||||||
let candidateId = "00000000-0000-0000-0000-000000000000";
|
let candidateId = "00000000-0000-0000-0000-000000000000";
|
||||||
let candidateName = profile.candidateName;
|
let candidateName = profile.candidateName;
|
||||||
|
let isDuplicate = false;
|
||||||
|
let existingCandidateData = null;
|
||||||
|
|
||||||
if (!isTest) {
|
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)
|
interface DbCandidate {
|
||||||
const { data: candidate, error: candidateError } = await supabase
|
id: string;
|
||||||
.from("candidates")
|
name: string;
|
||||||
.insert({
|
contact_info: {
|
||||||
name: profile.candidateName,
|
email: string;
|
||||||
contact_info: {
|
phone: string;
|
||||||
email: profile.email,
|
skills?: string[];
|
||||||
phone: profile.phone,
|
summary?: string;
|
||||||
skills: profile.skills || [],
|
};
|
||||||
summary: profile.summary || "",
|
|
||||||
cv_text: cleanText,
|
|
||||||
},
|
|
||||||
embedding,
|
|
||||||
})
|
|
||||||
.select("*")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (candidateError || !candidate) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: candidateError?.message || "Failed to insert candidate" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
candidateId = candidate.id;
|
// Check for duplicate candidate (by email or exact name match)
|
||||||
candidateName = candidate.name;
|
let existingCandidate: DbCandidate | null = null;
|
||||||
|
if (profile.email) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("candidates")
|
||||||
|
.select("*")
|
||||||
|
.eq("contact_info->>email", profile.email)
|
||||||
|
.maybeSingle();
|
||||||
|
existingCandidate = data as DbCandidate | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Decoupled: We no longer create an initial interview record on upload.
|
if (!existingCandidate && profile.candidateName) {
|
||||||
// Interviews are only queued when recruiter manually takes action.
|
const { data } = await supabase
|
||||||
|
.from("candidates")
|
||||||
|
.select("*")
|
||||||
|
.ilike("name", profile.candidateName)
|
||||||
|
.maybeSingle();
|
||||||
|
existingCandidate = data as DbCandidate | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingCandidate) {
|
||||||
|
if (duplicateAction === "check") {
|
||||||
|
isDuplicate = true;
|
||||||
|
existingCandidateData = {
|
||||||
|
id: existingCandidate.id,
|
||||||
|
name: existingCandidate.name,
|
||||||
|
contact_info: existingCandidate.contact_info,
|
||||||
|
};
|
||||||
|
} else if (duplicateAction === "overwrite") {
|
||||||
|
const { data: updated, error: updateError } = await supabase
|
||||||
|
.from("candidates")
|
||||||
|
.update({
|
||||||
|
name: profile.candidateName,
|
||||||
|
contact_info: {
|
||||||
|
email: profile.email,
|
||||||
|
phone: profile.phone,
|
||||||
|
skills: profile.skills || [],
|
||||||
|
summary: profile.summary || "",
|
||||||
|
cv_text: cleanText,
|
||||||
|
},
|
||||||
|
embedding,
|
||||||
|
})
|
||||||
|
.eq("id", existingCandidate.id)
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (updateError || !updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: updateError?.message || "Failed to overwrite candidate" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateId = updated.id;
|
||||||
|
candidateName = updated.name;
|
||||||
|
} else {
|
||||||
|
// ignore: insert as new candidate
|
||||||
|
const { data: candidate, error: candidateError } = await supabase
|
||||||
|
.from("candidates")
|
||||||
|
.insert({
|
||||||
|
name: profile.candidateName,
|
||||||
|
contact_info: {
|
||||||
|
email: profile.email,
|
||||||
|
phone: profile.phone,
|
||||||
|
skills: profile.skills || [],
|
||||||
|
summary: profile.summary || "",
|
||||||
|
cv_text: cleanText,
|
||||||
|
},
|
||||||
|
embedding,
|
||||||
|
})
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (candidateError || !candidate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: candidateError?.message || "Failed to insert candidate" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateId = candidate.id;
|
||||||
|
candidateName = candidate.name;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No duplicate found, insert new candidate
|
||||||
|
const { data: candidate, error: candidateError } = await supabase
|
||||||
|
.from("candidates")
|
||||||
|
.insert({
|
||||||
|
name: profile.candidateName,
|
||||||
|
contact_info: {
|
||||||
|
email: profile.email,
|
||||||
|
phone: profile.phone,
|
||||||
|
skills: profile.skills || [],
|
||||||
|
summary: profile.summary || "",
|
||||||
|
cv_text: cleanText,
|
||||||
|
},
|
||||||
|
embedding,
|
||||||
|
})
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (candidateError || !candidate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: candidateError?.message || "Failed to insert candidate" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateId = candidate.id;
|
||||||
|
candidateName = candidate.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
isDuplicate: true,
|
||||||
|
existingCandidate: existingCandidateData,
|
||||||
|
newProfile: profile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
candidateId,
|
candidateId,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -75,6 +75,33 @@ interface UploadFileStatus {
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DuplicateState {
|
||||||
|
fileName: string;
|
||||||
|
existingCandidate: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contact_info: {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
skills?: string[];
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
newProfile: {
|
||||||
|
candidateName?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
skills?: string[];
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
comparison: {
|
||||||
|
en: string;
|
||||||
|
es: string;
|
||||||
|
} | null;
|
||||||
|
onResolve: (action: "overwrite" | "ignore" | "cancel") => void;
|
||||||
|
}
|
||||||
|
|
||||||
export default function JobsPage() {
|
export default function JobsPage() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||||
|
|
@ -86,6 +113,7 @@ 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);
|
||||||
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
|
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
|
||||||
|
const [duplicateData, setDuplicateData] = useState<DuplicateState | null>(null);
|
||||||
|
|
||||||
// Evaluation states
|
// Evaluation states
|
||||||
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
|
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
|
||||||
|
|
@ -222,54 +250,114 @@ export default function JobsPage() {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadSuccess(null);
|
setUploadSuccess(null);
|
||||||
|
|
||||||
const uploadPromises = fileList.map(async (file, index) => {
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const file = fileList[i];
|
||||||
|
|
||||||
if (file.type !== "application/pdf") {
|
if (file.type !== "application/pdf") {
|
||||||
setUploadStatuses((prev) =>
|
setUploadStatuses((prev) =>
|
||||||
prev.map((status, idx) =>
|
prev.map((status, idx) =>
|
||||||
idx === index
|
idx === i
|
||||||
? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
|
? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
|
||||||
: status
|
: status
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let currentAction = "check";
|
||||||
const formData = new FormData();
|
let done = false;
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("jobId", selectedJob.id);
|
|
||||||
|
|
||||||
const res = await fetch("/candidates/api/parse-cv", {
|
while (!done) {
|
||||||
method: "POST",
|
try {
|
||||||
body: formData,
|
const formData = new FormData();
|
||||||
});
|
formData.append("file", file);
|
||||||
|
formData.append("duplicateAction", currentAction);
|
||||||
|
formData.append("jobId", selectedJob.id);
|
||||||
|
|
||||||
if (!res.ok) {
|
const res = await fetch("/candidates/api/parse-cv", {
|
||||||
const errData = await res.json();
|
method: "POST",
|
||||||
throw new Error(errData.error || "Failed to process CV");
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json();
|
||||||
|
throw new Error(errData.error || "Failed to process CV");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.isDuplicate) {
|
||||||
|
// Trigger AI Comparison summary
|
||||||
|
let comparisonResult = null;
|
||||||
|
try {
|
||||||
|
const compRes = await fetch("/api/candidates/compare", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
existingProfile: data.existingCandidate,
|
||||||
|
newProfile: data.newProfile,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (compRes.ok) {
|
||||||
|
const compData = await compRes.json();
|
||||||
|
comparisonResult = compData.comparison;
|
||||||
|
}
|
||||||
|
} catch (compErr) {
|
||||||
|
console.error("Comparison request failed:", compErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause and wait for user's decision
|
||||||
|
const userAction = await new Promise<"overwrite" | "ignore" | "cancel">((resolve) => {
|
||||||
|
setDuplicateData({
|
||||||
|
fileName: file.name,
|
||||||
|
existingCandidate: data.existingCandidate,
|
||||||
|
newProfile: data.newProfile,
|
||||||
|
comparison: comparisonResult,
|
||||||
|
onResolve: resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dialog
|
||||||
|
setDuplicateData(null);
|
||||||
|
|
||||||
|
if (userAction === "cancel") {
|
||||||
|
setUploadStatuses((prev) =>
|
||||||
|
prev.map((status, idx) =>
|
||||||
|
idx === i
|
||||||
|
? { ...status, status: "error" as const, errorMessage: "Upload cancelled by user" }
|
||||||
|
: status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
done = true;
|
||||||
|
} else {
|
||||||
|
// Resend request with overwrite or ignore parameter
|
||||||
|
currentAction = userAction === "overwrite" ? "overwrite" : "ignore";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Success
|
||||||
|
setUploadStatuses((prev) =>
|
||||||
|
prev.map((status, idx) =>
|
||||||
|
idx === i
|
||||||
|
? { ...status, status: "success" as const }
|
||||||
|
: status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Error uploading CV";
|
||||||
|
setUploadStatuses((prev) =>
|
||||||
|
prev.map((status, idx) =>
|
||||||
|
idx === i
|
||||||
|
? { ...status, status: "error" as const, errorMessage: msg }
|
||||||
|
: status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
done = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await res.json();
|
|
||||||
setUploadStatuses((prev) =>
|
|
||||||
prev.map((status, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...status, status: "success" as const }
|
|
||||||
: status
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : "Error uploading CV";
|
|
||||||
setUploadStatuses((prev) =>
|
|
||||||
prev.map((status, idx) =>
|
|
||||||
idx === index
|
|
||||||
? { ...status, status: "error" as const, errorMessage: msg }
|
|
||||||
: status
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
await Promise.all(uploadPromises);
|
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
|
||||||
// Refresh matches for current job
|
// Refresh matches for current job
|
||||||
|
|
@ -901,6 +989,105 @@ export default function JobsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Duplicate Detection dialog */}
|
||||||
|
{duplicateData && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-900">
|
||||||
|
Duplicate Candidate Detected / Candidato Duplicado Detectado
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
The system detected an existing candidate with the same email or name. / El sistema detectó un candidato existente con el mismo correo o nombre. ({duplicateData.fileName})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||||
|
{/* Existing Profile */}
|
||||||
|
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
|
||||||
|
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
|
||||||
|
Existing Profile / Perfil Existente
|
||||||
|
</h4>
|
||||||
|
<div className="text-sm font-bold text-slate-900">
|
||||||
|
{duplicateData.existingCandidate.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">
|
||||||
|
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
{duplicateData.existingCandidate.contact_info.summary && (
|
||||||
|
<p className="text-slate-600 mt-2 line-clamp-3">
|
||||||
|
{duplicateData.existingCandidate.contact_info.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Profile */}
|
||||||
|
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
|
||||||
|
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
|
||||||
|
Newly Uploaded Profile / Nuevo Perfil Cargado
|
||||||
|
</h4>
|
||||||
|
<div className="text-sm font-bold text-slate-900">
|
||||||
|
{duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">
|
||||||
|
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
{duplicateData.newProfile.summary && (
|
||||||
|
<p className="text-slate-600 mt-2 line-clamp-3">
|
||||||
|
{duplicateData.newProfile.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Comparison Summary */}
|
||||||
|
<div className="border border-slate-200 rounded-md p-3 bg-blue-50">
|
||||||
|
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2">
|
||||||
|
AI Comparison Summary / Resumen de Comparación de IA
|
||||||
|
</h4>
|
||||||
|
{duplicateData.comparison ? (
|
||||||
|
<div className="text-xs text-slate-600 leading-relaxed flex flex-col gap-2">
|
||||||
|
<p><strong>EN:</strong> {duplicateData.comparison.en}</p>
|
||||||
|
<p><strong>ES:</strong> {duplicateData.comparison.es}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500 italic">
|
||||||
|
Comparing profiles with AI... / Comparando perfiles con IA...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-slate-200">
|
||||||
|
<button
|
||||||
|
onClick={() => duplicateData.onResolve("cancel")}
|
||||||
|
className="px-3 py-1.5 border border-slate-200 rounded-md text-xs text-slate-600 bg-white hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
Cancel / Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => duplicateData.onResolve("ignore")}
|
||||||
|
className="px-3 py-1.5 bg-slate-600 hover:bg-slate-700 text-white rounded-md text-xs font-semibold transition"
|
||||||
|
>
|
||||||
|
Keep Both / Conservar ambos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => duplicateData.onResolve("overwrite")}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-xs font-semibold transition"
|
||||||
|
>
|
||||||
|
Overwrite / Sobrescribir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
app/api/candidates/compare/route.ts
Normal file
81
app/api/candidates/compare/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Missing GEMINI_API_KEY environment variable" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { existingProfile, newProfile } = body;
|
||||||
|
|
||||||
|
if (!existingProfile || !newProfile) {
|
||||||
|
return NextResponse.json({ error: "existingProfile and newProfile are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `You are an AI recruitment assistant. Compare the existing candidate profile against the newly uploaded CV profile for a candidate.
|
||||||
|
Analyze differences in skills, experience, and summary.
|
||||||
|
Determine:
|
||||||
|
1. If they appear to be the same person (updated resume) or two different people with the same name.
|
||||||
|
2. What new skills or experiences are present in the new profile compared to the old one.
|
||||||
|
|
||||||
|
Existing Profile:
|
||||||
|
Name: ${existingProfile.name}
|
||||||
|
Skills: ${JSON.stringify(existingProfile.skills || existingProfile.contact_info?.skills || [])}
|
||||||
|
Summary: ${existingProfile.summary || existingProfile.contact_info?.summary || ""}
|
||||||
|
|
||||||
|
New Profile:
|
||||||
|
Name: ${newProfile.candidateName || newProfile.name}
|
||||||
|
Skills: ${JSON.stringify(newProfile.skills || newProfile.contact_info?.skills || [])}
|
||||||
|
Summary: ${newProfile.summary || newProfile.contact_info?.summary || ""}
|
||||||
|
|
||||||
|
You MUST respond with a raw JSON object containing exactly these two keys:
|
||||||
|
- en: A concise 2-3 sentence summary of the differences in English.
|
||||||
|
- es: A concise 2-3 sentence summary of the differences in Spanish.
|
||||||
|
|
||||||
|
Do not include any markdown formatting, code blocks, or text outside the JSON.`;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{
|
||||||
|
parts: [{ text: prompt }]
|
||||||
|
}],
|
||||||
|
generationConfig: {
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
return NextResponse.json({ error: `Gemini API error: ${response.status} - ${errText}` }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
|
|
||||||
|
if (!textContent) {
|
||||||
|
return NextResponse.json({ error: "Failed to generate comparison from Gemini" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedComparison = JSON.parse(textContent.trim());
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
comparison: parsedComparison,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Error in compare API:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,10 @@ interface CandidateScore {
|
||||||
riskLevel: string;
|
riskLevel: string;
|
||||||
};
|
};
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
job_id?: string;
|
||||||
|
jobs?: {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InterviewDetail {
|
interface InterviewDetail {
|
||||||
|
|
@ -19,6 +23,10 @@ interface InterviewDetail {
|
||||||
stage: string;
|
stage: string;
|
||||||
interview_date: string;
|
interview_date: string;
|
||||||
feedback: string | null;
|
feedback: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
jobs?: {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RankedCandidate {
|
interface RankedCandidate {
|
||||||
|
|
@ -35,6 +43,20 @@ interface RankedCandidate {
|
||||||
interview?: InterviewDetail | null;
|
interview?: InterviewDetail | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DBCandidate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contact_info: {
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
skills?: string[];
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
created_at: string;
|
||||||
|
scores?: CandidateScore[];
|
||||||
|
interviews?: InterviewDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const supabase = createServerSupabaseClient();
|
const supabase = createServerSupabaseClient();
|
||||||
|
|
@ -144,20 +166,33 @@ 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, along with scores and interviews, including job titles
|
||||||
const { data: candidates, error } = await supabase
|
const { data: candidates, error } = await supabase
|
||||||
.from("candidates")
|
.from("candidates")
|
||||||
.select("*, scores(*)")
|
.select(`
|
||||||
.order("created_at", { ascending: false })
|
*,
|
||||||
.order("created_at", { referencedTable: "scores", ascending: false });
|
scores (
|
||||||
|
*,
|
||||||
|
jobs (
|
||||||
|
title
|
||||||
|
)
|
||||||
|
),
|
||||||
|
interviews (
|
||||||
|
*,
|
||||||
|
jobs (
|
||||||
|
title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order("created_at", { 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
|
// Safeguard: Sort and normalize scores inside each candidate in Javascript as well
|
||||||
const typedCandidates = candidates || [];
|
const typedCandidates = (candidates as unknown as DBCandidate[]) || [];
|
||||||
typedCandidates.forEach(cand => {
|
typedCandidates.forEach((cand: DBCandidate) => {
|
||||||
if (cand.scores && Array.isArray(cand.scores)) {
|
if (cand.scores && Array.isArray(cand.scores)) {
|
||||||
cand.scores.forEach((s: CandidateScore) => {
|
cand.scores.forEach((s: CandidateScore) => {
|
||||||
if (s.ai_score <= 1.0) {
|
if (s.ai_score <= 1.0) {
|
||||||
|
|
@ -179,6 +214,13 @@ export async function GET(request: NextRequest) {
|
||||||
});
|
});
|
||||||
cand.scores.sort((a: CandidateScore, b: CandidateScore) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
cand.scores.sort((a: CandidateScore, b: CandidateScore) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
}
|
}
|
||||||
|
if (cand.interviews && Array.isArray(cand.interviews)) {
|
||||||
|
cand.interviews.sort((a: InterviewDetail, b: InterviewDetail) => {
|
||||||
|
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
|
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(typedCandidates);
|
return NextResponse.json(typedCandidates);
|
||||||
|
|
@ -188,3 +230,29 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const supabase = createServerSupabaseClient();
|
||||||
|
const id = request.nextUrl.searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Candidate ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("candidates")
|
||||||
|
.delete()
|
||||||
|
.eq("id", id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "Candidate deleted successfully" });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
|
||||||
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,30 @@ const config: Config = {
|
||||||
white: "#ffffff",
|
white: "#ffffff",
|
||||||
slate: {
|
slate: {
|
||||||
50: "#f8fafc",
|
50: "#f8fafc",
|
||||||
|
200: "#e2e8f0",
|
||||||
|
500: "#64748b",
|
||||||
600: "#475569",
|
600: "#475569",
|
||||||
900: "#0f172a",
|
900: "#0f172a",
|
||||||
},
|
},
|
||||||
blue: {
|
blue: {
|
||||||
|
50: "#eff6ff",
|
||||||
|
200: "#bfdbfe",
|
||||||
600: "#2563eb",
|
600: "#2563eb",
|
||||||
700: "#1d4ed8",
|
700: "#1d4ed8",
|
||||||
},
|
},
|
||||||
|
red: {
|
||||||
|
50: "#fef2f2",
|
||||||
|
200: "#fecaca",
|
||||||
|
600: "#dc2626",
|
||||||
|
700: "#b91c1c",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
50: "#f0fdf4",
|
||||||
|
100: "#dcfce7",
|
||||||
|
200: "#bbf7d0",
|
||||||
|
700: "#15803d",
|
||||||
|
800: "#166534",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue