Download app.js from Gehan77/CV: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/spaces/Gehan77/CV/resolve/main/app.js
- Command line
-
hf download hf://spaces/Gehan77/CV/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Gehan77/CV/resolve/main/app.js
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]); | |
| }); | |