#!/usr/bin/env node /** * jd-similarity.mjs — deterministic CV reuse recommendation for similar JDs. * * This is deliberately a recommendation layer. It never evaluates a JD and * never deletes or overwrites an existing CV. * * Usage: * node jd-similarity.mjs new-jd.txt previous-jd-or-cv.txt */ import { readFileSync } from 'fs'; import { isMainModule } from './lib/is-main-module.mjs'; const STOP_WORDS = new Set([ 'and', 'the', 'for', 'with', 'from', 'that', 'this', 'have', 'will', 'you', 'your', 'our', 'are', 'not', 'to', 'of', 'in', 'on', 'or', 'a', 'an', '负责', '岗位', '工作', '相关', '具备', '以及', '能够', '进行', '通过', '需要', ]); const LEVELS = [ ['intern', '实习', '实习生', '应届'], ['junior', '初级'], ['mid', '中级'], ['senior', '高级', '资深'], ['staff', 'principal', 'lead', '负责人'], ]; /** Tokenize JD/CV text into normalized, stop-word-filtered terms. */ export function tokenize(text) { return new Set( String(text ?? '') .toLowerCase() .match(/[\p{L}\p{N}+#./-]+/gu) ?.map(token => token.replace(/^[./-]+|[./-]+$/g, '')) .filter(token => token && (token.length > 1 || /\d/.test(token)) && !STOP_WORDS.has(token)) || [], ); } /** Calculate Jaccard similarity between two texts or token sets. */ export function jaccardSimilarity(left, right) { const a = left instanceof Set ? left : tokenize(left); const b = right instanceof Set ? right : tokenize(right); if (!a.size && !b.size) return 1; if (!a.size || !b.size) return 0; let intersection = 0; for (const token of a) if (b.has(token)) intersection++; return intersection / (a.size + b.size - intersection); } /** * Level words that are also ordinary English, mapped to the words that follow * them in their NON-seniority sense. JD boilerplate is full of these: "Principal * responsibilities" means "main duties", "mid-market" is a customer segment, and * "lead" is usually a verb. Matching them as job levels made the gate below fire * on postings of identical seniority. * * Only the trailing word is inspected: "Principal Engineer" and "Lead Engineer" * stay levels because `engineer` is not in any of these lists. */ const NON_LEVEL_FOLLOWERS = { principal: ['responsibilities', 'responsibility', 'duties', 'accountabilities', 'objectives', 'purpose', 'tasks', 'activities'], lead: ['to', 'the', 'a', 'an', 'our', 'and', 'or', 'by', 'on', 'in', 'for', 'with', 'from', 'mentoring', 'projects'], mid: ['market', 'size', 'sized', 'cap', 'tier', 'funnel', 'term', 'sized-company'], }; /** Whether a level word at `index` reads as a job level rather than plain English. */ function readsAsLevel(word, normalized, index) { const followers = NON_LEVEL_FOLLOWERS[word]; if (!followers) return true; const after = normalized.slice(index + word.length).match(/^[^a-z0-9]*([a-z0-9-]+)/); return !after || !followers.includes(after[1]); } /** * Every distinct seniority level named in the text, as LEVELS indices. * * Returns ALL of them rather than the first: a document may name several (a CV * showing career progression names each rank it held), and `findIndex` used to * collapse that to whichever appeared earliest in the LEVELS table — reading a * junior-to-senior CV as junior. */ function levelsIn(text) { const normalized = String(text ?? '').toLowerCase(); const found = new Set(); LEVELS.forEach((words, level) => { for (const word of words) { if (/^[\p{Script=Han}]+$/u.test(word)) { if (normalized.includes(word)) { found.add(level); break; } continue; } const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`(?:^|[^a-z0-9])(${escaped})(?=$|[^a-z0-9])`, 'gi'); let match; while ((match = pattern.exec(normalized)) !== null) { if (readsAsLevel(word, normalized, match.index + match[0].length - word.length)) { found.add(level); break; } } if (found.has(level)) break; } }); return found; } /** * Detect the seniority level of a document, or -1 when it has no single * unambiguous one. Naming several levels counts as ambiguous, NOT as the lowest * one: the gate below exists to catch a clear level difference, and a guess is * worse than standing down and letting the similarity score decide. */ function levelOf(text) { const levels = levelsIn(text); return levels.size === 1 ? [...levels][0] : -1; } /** Return whether the new JD and previous document have different seniority levels. */ export function hardMismatch(newJd, previousText) { const newLevel = levelOf(newJd); const previousLevel = levelOf(previousText); return newLevel >= 0 && previousLevel >= 0 && newLevel !== previousLevel; } /** Recommend CV reuse, reuse with edits, or regeneration for a new JD. */ export function recommendCvReuse(newJd, previousText, options = {}) { const score = jaccardSimilarity(newJd, previousText); const high = Number(options.highThreshold ?? 0.72); const medium = Number(options.mediumThreshold ?? 0.45); if (hardMismatch(newJd, previousText)) { return { decision: 'regenerate', score, reason: 'level-mismatch' }; } if (score >= high) return { decision: 'reuse', score, reason: 'high-similarity' }; if (score >= medium) return { decision: 'reuse-with-edits', score, reason: 'medium-similarity' }; return { decision: 'regenerate', score, reason: 'low-similarity' }; } // ── CLI ───────────────────────────────────────────────────────────── const KNOWN_FLAGS = ['--help', '-h']; const USAGE = `Usage: node jd-similarity.mjs new-jd.txt previous-jd-or-cv.txt node jd-similarity.mjs --help # print this usage block and exit`; function parseArgs(argv) { const args = argv.slice(2); if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); process.exit(0); } const unknownFlags = args.filter(a => a.startsWith('-') && !KNOWN_FLAGS.includes(a)); if (unknownFlags.length) { console.error(`Error: unrecognized flag(s): ${unknownFlags.join(', ')}. Valid flags: ${KNOWN_FLAGS.join(', ')}`); console.error(USAGE); process.exit(1); } const [newJdPath, previousPath] = args; if (args.length !== 2 || !newJdPath || !previousPath) { console.error('Error: expected two file paths.'); console.error(USAGE); process.exit(1); } return { newJdPath, previousPath }; } if (isMainModule(import.meta.url)) { const { newJdPath, previousPath } = parseArgs(process.argv); try { const result = recommendCvReuse(readFileSync(newJdPath, 'utf8'), readFileSync(previousPath, 'utf8')); console.log(JSON.stringify(result, null, 2)); } catch (error) { console.error(`Unable to read input files: ${error.message}`); process.exit(1); } }