CV / app.js
Gehan77's picture
Prefer current associate role in browser extraction
be66597 verified
Raw History Blame Contribute Delete
12 kB
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 = `
<div class="result-top">
<div><h3>${verdict}</h3><p>Model-estimated relevance for this job and candidate profile.</p></div>
<div class="score-number">${percent}%</div>
</div>
<div class="score-bar"><span style="width:${Math.max(2, probability * 100)}%"></span></div>
<p>Decision threshold: ${(DECISION_THRESHOLD * 100).toFixed(1)}%</p>
<p class="result-note">This is an assistive ranking signal only. Review the original CV and job requirements before making any hiring decision.</p>
`;
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 <span>→</span>';
}
}
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]);
});