feat: scale up candidate evaluation and promotion

This commit is contained in:
Gabriel Ramos 2026-06-10 08:46:36 -04:00
parent 24c88915b0
commit 529958f492
8 changed files with 516 additions and 221 deletions

3
.gitignore vendored
View file

@ -40,3 +40,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# agents config
.agents/

View file

@ -57,47 +57,7 @@ export async function POST(request: NextRequest) {
const jobRequirementsText = (job.requirements as { text?: string })?.text || ""; const jobRequirementsText = (job.requirements as { text?: string })?.text || "";
// 3. Fetch or create interview record // 3. Call n8n webhook passing jobId instead of interviewId
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; let n8nResponseData = null;
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL; const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
@ -110,7 +70,7 @@ export async function POST(request: NextRequest) {
}, },
body: JSON.stringify({ body: JSON.stringify({
candidateId, candidateId,
interviewId, jobId,
candidateName, candidateName,
candidateEmail: email, candidateEmail: email,
text: cvText, text: cvText,
@ -141,7 +101,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
candidateId, candidateId,
interviewId, jobId,
candidateName, candidateName,
n8nResponse: n8nResponseData, n8nResponse: n8nResponseData,
}); });

View file

@ -8,8 +8,6 @@ export async function POST(request: NextRequest) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
const jobId = formData.get("jobId") as string | null;
if (!file) { if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
} }
@ -72,20 +70,8 @@ export async function POST(request: NextRequest) {
candidateId = candidate.id; candidateId = candidate.id;
candidateName = candidate.name; candidateName = candidate.name;
// Create an initial interview record if jobId is provided (but do not trigger n8n evaluate webhook yet) // Decoupled: We no longer create an initial interview record on upload.
if (jobId) { // Interviews are only queued when recruiter manually takes action.
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);
}
}
} }
return NextResponse.json({ return NextResponse.json({

View file

@ -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 });
}
}

View file

@ -36,9 +36,39 @@ interface Candidate {
}; };
similarity?: number; similarity?: number;
scores?: Score[]; scores?: Score[];
interview?: {
id: string;
stage: string;
interview_date: string;
feedback: string | null;
} | null;
created_at: string; 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() { 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);
@ -52,6 +82,14 @@ export default function JobsPage() {
// Evaluation states // Evaluation states
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({}); const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
const [isBulkEvaluating, setIsBulkEvaluating] = useState(false);
const [bulkEvalProgress, setBulkEvalProgress] = useState("");
// Promotion states
const [promotingIds, setPromotingIds] = useState<Record<string, boolean>>({});
// Display states
const [showHiddenCandidates, setShowHiddenCandidates] = useState(false);
// Form states // Form states
const [newTitle, setNewTitle] = useState(""); 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 ( return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Create Form & Vacancies List */} {/* Left Column: Create Form & Vacancies List */}
@ -391,54 +524,60 @@ export default function JobsPage() {
{/* Matches List */} {/* Matches List */}
<div> <div>
<h3 className="text-base font-bold text-slate-900 mb-3"> {/* Computed lists */}
Candidates & Compatibility Index {(() => {
</h3> const getSkillsOverlap = (match: Candidate) => {
{loadingMatches ? (
<p className="text-slate-500 text-sm">Finding matches...</p>
) : matches.length === 0 ? (
<p className="text-slate-500 text-sm">
No candidates have been uploaded or matched yet.
</p>
) : (
<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 jobSkills = selectedJob.requirements.skills || [];
const candidateSkills = match.contact_info.skills || []; const candidateSkills = match.contact_info.skills || [];
const matchedSkills = jobSkills.filter((js) =>
const skillsMatch = (candSkill: string, jobSkill: string): boolean => { candidateSkills.some((cs) => skillsMatch(cs, js))
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 => const missingSkills = jobSkills.filter((js) =>
!candidateSkills.some(cs => skillsMatch(cs, js)) !candidateSkills.some((cs) => skillsMatch(cs, js))
); );
const overlapCount = matchedSkills.length; const overlapCount = matchedSkills.length;
const totalRequired = jobSkills.length; const totalRequired = jobSkills.length;
const matchPct = totalRequired > 0 ? Math.round((overlapCount / totalRequired) * 100) : 0; const matchPct = totalRequired > 0 ? Math.round((overlapCount / totalRequired) * 100) : 0;
const isPotentialMatch = matchPct >= 75; const isPotentialMatch = matchPct >= 75;
return {
matchedSkills,
missingSkills,
overlapCount,
totalRequired,
matchPct,
isPotentialMatch,
};
};
const visibleMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = [];
const hiddenMatches: { candidate: Candidate; overlap: SkillsOverlap }[] = [];
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 ( return (
<div <div
key={match.id} key={match.id}
@ -487,7 +626,7 @@ export default function JobsPage() {
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{/* Display matched skills in green */} {/* Display matched skills in green */}
{matchedSkills.map(skill => ( {matchedSkills.map((skill: string) => (
<span <span
key={skill} key={skill}
className="px-2 py-0.5 bg-green-100 text-green-800 border border-green-200 text-xs rounded-md font-medium" className="px-2 py-0.5 bg-green-100 text-green-800 border border-green-200 text-xs rounded-md font-medium"
@ -497,7 +636,7 @@ export default function JobsPage() {
))} ))}
{/* Display missing skills in light red/gray dashed */} {/* Display missing skills in light red/gray dashed */}
{missingSkills.map(skill => ( {missingSkills.map((skill: string) => (
<span <span
key={skill} key={skill}
className="px-2 py-0.5 bg-white border border-slate-200 border-dashed text-slate-400 text-xs rounded-md" className="px-2 py-0.5 bg-white border border-slate-200 border-dashed text-slate-400 text-xs rounded-md"
@ -517,7 +656,7 @@ export default function JobsPage() {
{/* Bottom evaluation / action panel */} {/* 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 className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-3 border-t border-slate-100">
<div> <div className="flex-1">
{latestScore ? ( {latestScore ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
@ -539,25 +678,138 @@ export default function JobsPage() {
)} )}
</div> </div>
<div className="self-end sm:self-center"> <div className="flex flex-wrap items-center gap-2 self-end sm:self-center">
{/* Run/Re-run AI evaluation */}
<button <button
onClick={() => handleEvaluate(match.id)} onClick={() => handleEvaluate(match.id)}
disabled={evaluatingIds[match.id]} disabled={evaluatingIds[match.id] || isBulkEvaluating}
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" className="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-md border border-slate-200 transition duration-200 disabled:opacity-50"
> >
{evaluatingIds[match.id] {evaluatingIds[match.id]
? "Evaluating (n8n)..." ? "Evaluating..."
: latestScore : latestScore
? "Re-run Deep AI" ? "Re-run AI"
: "Run Deep AI Evaluation"} : "Run AI Evaluation"}
</button> </button>
{/* Promote to Interview Pipeline */}
{match.interview ? (
<span className="px-3 py-1.5 bg-green-50 border border-green-200 text-green-700 text-xs font-semibold rounded-md">
Promoted ({match.interview.stage})
</span>
) : (
<button
onClick={() => handlePromote(match.id)}
disabled={
promotingIds[match.id] ||
isBulkEvaluating ||
isUnqualified ||
!isPotentialMatch
}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 disabled:bg-slate-100 disabled:text-slate-400 disabled:border disabled:border-slate-200"
title={
isUnqualified
? "Cannot promote unqualified candidates"
: !isPotentialMatch
? "Skill overlap too low to promote"
: "Promote to Interviews"
}
>
{promotingIds[match.id] ? "Promoting..." : "Promote to Interviews"}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
); );
})} };
return (
<div className="flex flex-col gap-6">
{/* Toolbar / Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200">
<div className="flex items-center gap-2">
<span className="text-slate-600 text-sm font-semibold">
Showing {visibleMatches.length} qualified matches
</span>
{visibleMatchesToEval.length > 0 && (
<span className="text-xs text-slate-500 font-medium">
({visibleMatchesToEval.length} unevaluated)
</span>
)}
</div>
{visibleMatchesToEval.length > 0 && (
<button
onClick={handleBulkEvaluate}
disabled={isBulkEvaluating}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold rounded-md transition duration-200 disabled:opacity-50 shadow-sm flex items-center gap-1.5"
>
{isBulkEvaluating ? (
<>
<span className="w-2 h-2 rounded-full bg-white animate-ping"></span>
{bulkEvalProgress || "Evaluating..."}
</>
) : (
`Bulk Run AI Evaluation (${visibleMatchesToEval.length})`
)}
</button>
)}
</div>
{/* Visible Matches List */}
{loadingMatches ? (
<p className="text-slate-500 text-sm">Finding matches...</p>
) : visibleMatches.length === 0 && !loadingMatches ? (
<div className="p-8 text-center border border-slate-100 rounded-lg bg-slate-50/50">
<p className="text-slate-500 text-sm font-medium">No active potential matches found.</p>
<p className="text-slate-400 text-xs mt-1">Upload CVs or check the mismatch/unqualified list below.</p>
</div>
) : (
<div className="flex flex-col gap-4">
{visibleMatches.map(({ candidate, overlap }) =>
renderCandidateCard(candidate, overlap)
)}
</div> </div>
)} )}
{/* Expandable Hidden Matches List */}
{hiddenMatches.length > 0 && (
<div className="border border-slate-200 rounded-lg overflow-hidden">
<button
onClick={() => setShowHiddenCandidates(!showHiddenCandidates)}
className="w-full flex items-center justify-between p-4 bg-slate-50 hover:bg-slate-100 transition duration-200 border-b border-slate-200"
>
<div className="flex items-center gap-2 text-slate-700 font-semibold text-sm">
<span>Mismatched or Unqualified Candidates</span>
<span className="px-2 py-0.5 bg-slate-200 text-slate-800 text-xs rounded-full font-bold">
{hiddenMatches.length}
</span>
</div>
<svg
className={`w-5 h-5 text-slate-500 transform transition-transform duration-200 ${
showHiddenCandidates ? "rotate-180" : ""
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showHiddenCandidates && (
<div className="p-4 bg-slate-50/50 border-t border-slate-200 flex flex-col gap-4">
{hiddenMatches.map(({ candidate, overlap }) =>
renderCandidateCard(candidate, overlap)
)}
</div>
)}
</div>
)}
</div>
);
})()}
</div> </div>
</div> </div>
) : ( ) : (

View file

@ -14,15 +14,25 @@ interface CandidateScore {
created_at: string; created_at: string;
} }
interface InterviewDetail {
id: string;
stage: string;
interview_date: string;
feedback: string | null;
}
interface RankedCandidate { interface RankedCandidate {
id: string; id: string;
name: string; name: string;
contact_info: { contact_info: {
email: string; email: string;
phone: string; phone: string;
skills?: string[];
summary?: string;
}; };
similarity?: number; similarity?: number;
scores?: CandidateScore[]; scores?: CandidateScore[];
interview?: InterviewDetail | null;
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@ -62,16 +72,29 @@ export async function GET(request: NextRequest) {
const candidatesList = (rankedCandidates as unknown as RankedCandidate[]) || []; 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) { if (candidatesList.length > 0) {
const candidateIds = candidatesList.map((c) => c.id); const candidateIds = candidatesList.map((c) => c.id);
const { data: scores, error: scoresError } = await supabase const { data: scores, error: scoresError } = await supabase
.from("scores") .from("scores")
.select("*, interviews!inner(job_id)") .select("*")
.in("candidate_id", candidateIds) .in("candidate_id", candidateIds)
.eq("interviews.job_id", jobId) .eq("job_id", jobId)
.order("created_at", { ascending: false }); .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<string, InterviewDetail>();
if (!interviewsError && interviews) {
interviews.forEach((i) => {
interviewsMap.set(i.candidate_id, i as unknown as InterviewDetail);
});
}
if (!scoresError && scores) { if (!scoresError && scores) {
const typedScores = (scores as unknown as CandidateScore[]) || []; const typedScores = (scores as unknown as CandidateScore[]) || [];
@ -109,10 +132,12 @@ export async function GET(request: NextRequest) {
candidatesList.forEach((c) => { candidatesList.forEach((c) => {
c.scores = scoresMap.get(c.id) || []; c.scores = scoresMap.get(c.id) || [];
c.interview = interviewsMap.get(c.id) || null;
}); });
} else { } else {
candidatesList.forEach((c) => { candidatesList.forEach((c) => {
c.scores = []; c.scores = [];
c.interview = interviewsMap.get(c.id) || null;
}); });
} }
} }

View file

@ -345,8 +345,8 @@ async function main() {
type: "string", type: "string",
}, },
{ {
name: "interview_id", name: "job_id",
value: "={{ $('Webhook Trigger').item.json.body.interviewId }}", value: "={{ $('Webhook Trigger').item.json.body.jobId }}",
type: "string", type: "string",
}, },
{ {

View file

@ -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;