import { AutoModelForSequenceClassification, AutoTokenizer, } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/+esm"; import * as pdfjsLib from "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/pdf.min.mjs"; const MODEL_ID = "Gehan77/cv-match-browser"; const DECISION_THRESHOLD = 0.4399277865886688; pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/pdf.worker.min.mjs"; const $ = (id) => document.getElementById(id); const cvFile = $("cvFile"); const dropzone = $("dropzone"); const fileLabel = $("fileLabel"); const extractButton = $("extractButton"); const scoreButton = $("scoreButton"); const status = $("status"); const preview = $("preview"); const result = $("result"); let selectedFile = null; let modelPromise = null; function setStatus(message, type = "") { status.textContent = message; status.className = `status ${type}`.trim(); } function clean(value, fallback = "Not specified") { const text = String(value ?? "").replace(/\s+/g, " ").trim(); return text || fallback; } function cleanList(value) { const text = clean(value); if (text === "Not specified") return text; const items = text.split(/[|,;\n]+/).map((item) => item.trim()).filter(Boolean); return items.length ? items.join(", ") : "Not specified"; } const headerRules = [ ["summary", /^(?:(?:professional\s+)?summary|profile|about\s+me|objective|career\s+objective)$/i], ["experience", /^(?:(?:professional\s+)?(?:work\s+)?experience|employment\s+history|career\s+history)$/i], ["education", /^(?:education(?:al\s+background)?|academic\s+qualifications?)$/i], ["skills", /^(?:(?:technical\s+|core\s+|key\s+|professional\s+)?skills?|competenc(?:y|ies)|technologies)$/i], ]; function headerName(line) { const candidate = line.replace(/[#:|\-–—]+$/g, "").trim(); if (candidate.length > 45) return null; return headerRules.find(([, pattern]) => pattern.test(candidate))?.[0] ?? null; } function parseSections(text) { const sections = { other: [] }; let current = "other"; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.replace(/^[\s•*\-–—]+/, "").trim(); if (!line) continue; const header = headerName(line); if (header) { current = header; sections[current] ??= []; continue; } sections[current] ??= []; sections[current].push(line); } return sections; } function firstNonContactLine(text) { for (const rawLine of text.split(/\r?\n/).slice(0, 12)) { const line = rawLine.replace(/^[\s•*\-–—]+/, "").trim(); if (!line || headerName(line)) continue; if (/@|https?:\/\/|www\.|(?:\+?\d[\d ()-]{7,})/i.test(line)) continue; if (line.split(/\s+/).length <= 10) return line; } return ""; } function sectionText(section = [], limit = 800) { return section.join(" ").trim().slice(0, limit); } function sectionItems(section = [], limit = 1200) { return section.filter(Boolean).join("; ").slice(0, limit); } function inlineSection(text, labels, nextLabels, limit) { const flat = text.replace(/\s+/g, " "); const match = flat.match(new RegExp(`\\b(?:${labels})\\b\\s*[:\\-]\\s*(.*?)(?=\\s+\\b(?:${nextLabels})\\b\\s*[:\\-]\\s*|$)`, "i")); return match ? match[1].trim().slice(0, limit) : ""; } function extractCandidateFields(text) { const sections = parseSections(text); const allText = text.replace(/\s+/g, " "); const firstLine = firstNonContactLine(text); const roleKeywords = ["developer", "engineer", "manager", "analyst", "designer", "specialist", "officer", "assistant", "consultant", "accountant", "teacher", "intern"]; let role = ""; for (const line of (sections.other ?? []).slice(0, 8)) { if (line === firstLine) continue; const parts = line.split(/\s*(?:\||•|·)\s*|\s{2,}/); role = parts.find((part) => roleKeywords.some((keyword) => part.toLowerCase().includes(keyword)))?.trim() || ""; if (role) break; } role ||= firstLine; const seniorityMatch = allText.match(/\b(associate|intern|internship|entry[- ]?level|junior|mid[- ]?level|intermediate|senior|lead|principal|manager|director|executive)\b/i); const seniority = seniorityMatch ? seniorityMatch[1].replace("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) : ""; const yearsMatch = allText.match(/\b(\d+(?:\.\d+)?)\+?\s*(?:years?|yrs?)\b/i); const industryTerms = ["FinTech", "Banking", "Finance", "Software", "Information Technology", "Healthcare", "Education", "Marketing", "Tourism", "Retail", "Logistics", "Construction", "Manufacturing", "Telecommunications", "Insurance", "Hospitality", "Government", "E-commerce"]; let industry = industryTerms.find((term) => new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test(allText)) ?? ""; industry ||= allText.match(/\bindustry\s*[:\-]\s*([^|]+)/i)?.[1]?.trim() || ""; const summary = sectionText(sections.summary) || inlineSection(text, "professional\\s+summary|summary|profile|objective", "experience|education|skills|certifications", 900) || sectionText((sections.other ?? []).slice(1, 5), 900); const education = sectionItems(sections.education, 600) || inlineSection(text, "education|academic\\s+qualifications?", "experience|skills|summary|certifications", 600); const skills = sectionItems(sections.skills) || inlineSection(text, "technical\\s+skills|core\\s+skills|skills|competenc(?:y|ies)|technologies", "experience|education|summary|certifications", 1200); const experience = sectionItems(sections.experience, 1400) || inlineSection(text, "professional\\s+experience|work\\s+experience|experience|employment\\s+history", "education|skills|summary|certifications", 1400); return { candidateRole: role, candidateSeniority: seniority, yearsExperience: yearsMatch?.[1] ?? "", candidateIndustry: industry, education, candidateSkills: skills, summary, experienceBullets: experience, }; } function applyCandidateFields(fields) { for (const [id, value] of Object.entries(fields)) $(id).value = value; } async function extractPdfText(file) { const data = new Uint8Array(await file.arrayBuffer()); const pdf = await pdfjsLib.getDocument({ data }).promise; const pages = []; for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { const page = await pdf.getPage(pageNumber); const content = await page.getTextContent(); const lines = []; let currentY = null; for (const item of content.items) { const text = item.str?.trim(); if (!text) continue; const y = item.transform?.[5] ?? null; if (currentY === null || y === null || Math.abs(y - currentY) > 3) { lines.push(text); currentY = y; } else { lines[lines.length - 1] += ` ${text}`; } } pages.push(lines.join("\n")); } return pages.join("\n"); } async function extractFileText(file) { const extension = file.name.split(".").pop().toLowerCase(); if (extension === "txt") return file.text(); if (extension === "pdf") return extractPdfText(file); if (extension === "docx") { if (!window.mammoth) throw new Error("DOCX reader could not be loaded. Try a PDF or TXT file."); const result = await window.mammoth.extractRawText({ arrayBuffer: await file.arrayBuffer() }); return result.value; } throw new Error("Supported CV formats are PDF, DOCX and TXT."); } async function parseUploadedCv() { if (!selectedFile) return; extractButton.disabled = true; setStatus("Reading your CV in the browser…"); try { const text = (await extractFileText(selectedFile)).replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim(); if (!text) throw new Error("No selectable text found. This may be a scanned PDF."); preview.value = `${text.slice(0, 2500)}${text.length > 2500 ? "…" : ""}`; const fields = extractCandidateFields(text); applyCandidateFields(fields); const filled = Object.values(fields).filter(Boolean).length; setStatus(`Extracted ${filled} of 8 candidate fields. Please review them before scoring.`, "success"); } catch (error) { setStatus(error.message || "Could not read this file.", "error"); } finally { extractButton.disabled = false; } } function setSelectedFile(file) { if (!file) return; selectedFile = file; fileLabel.textContent = file.name; extractButton.disabled = false; setStatus("File selected. Click Extract CV details to fill the form."); } function buildJobText() { return clean(` Job Title: ${clean($("jobTitle").value)}. Required Seniority: ${clean($("jobSeniority").value)}. Industry: ${clean($("jobIndustry").value)}. Must-Have Skills: ${cleanList($("mustHaveSkills").value)}. Nice-to-Have Skills: ${cleanList($("niceToHaveSkills").value)}. Job Description: ${clean($("jobDescription").value)}. Responsibilities: ${cleanList($("responsibilities").value)}. Requirements: ${cleanList($("requirements").value)}. `); } function buildResumeText() { return clean(` Candidate Role: ${clean($("candidateRole").value)}. Seniority Level: ${clean($("candidateSeniority").value)}. Years of Experience: ${clean($("yearsExperience").value)}. Industry Experience: ${clean($("candidateIndustry").value)}. Education: ${clean($("education").value)}. Skills: ${cleanList($("candidateSkills").value)}. Professional Summary: ${clean($("summary").value)}. Experience Highlights: ${cleanList($("experienceBullets").value)}. `); } async function loadBrowserModel() { if (!modelPromise) { modelPromise = Promise.all([ AutoTokenizer.from_pretrained(MODEL_ID), AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype: "fp32" }), ]); } return modelPromise; } async function scoreMatch() { scoreButton.disabled = true; scoreButton.textContent = "Loading model…"; setStatus("The browser model is loading. The first load may take a minute."); result.hidden = true; try { const [tokenizer, model] = await loadBrowserModel(); setStatus("Scoring the job and candidate profile…"); const inputs = await tokenizer(buildJobText(), { text_pair: buildResumeText(), padding: "max_length", truncation: true, max_length: 256, }); const output = await model(inputs); const logit = Number(output.logits.data[0]); const probability = 1 / (1 + Math.exp(-logit)); const verdict = probability >= DECISION_THRESHOLD ? "Suitable match" : "Needs review"; const percent = (probability * 100).toFixed(1); result.innerHTML = `
Model-estimated relevance for this job and candidate profile.
Decision threshold: ${(DECISION_THRESHOLD * 100).toFixed(1)}%
This is an assistive ranking signal only. Review the original CV and job requirements before making any hiring decision.
`; result.hidden = false; setStatus("Match scored in your browser.", "success"); } catch (error) { setStatus(`Could not score this profile: ${error.message || error}`, "error"); } finally { scoreButton.disabled = false; scoreButton.innerHTML = 'Score match →'; } } cvFile.addEventListener("change", (event) => setSelectedFile(event.target.files[0])); extractButton.addEventListener("click", parseUploadedCv); scoreButton.addEventListener("click", scoreMatch); dropzone.addEventListener("dragover", (event) => { event.preventDefault(); dropzone.classList.add("dragging"); }); dropzone.addEventListener("dragleave", () => dropzone.classList.remove("dragging")); dropzone.addEventListener("drop", (event) => { event.preventDefault(); dropzone.classList.remove("dragging"); setSelectedFile(event.dataTransfer.files[0]); });