Files
santifer--career-ops/invite-match.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

465 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* invite-match.mjs — Interview-Invite → Tracker Matcher for career-ops
*
* Recruiter calendar/ATS invite emails frequently name only the company
* (generic subject lines like "Schedule Your Phone Screen") with no job
* title or req number. Finding which `data/applications.md` row an invite
* belongs to otherwise means a manual grep every time.
*
* This script extracts a company name (and, if present, a date and a
* req/job-ID-looking token) from pasted invite text, fuzzy-matches it
* against the tracker's Company column, and ranks candidates when the same
* company has multiple applications — which is common. A silent wrong guess
* is worse than showing a short ranked list, so ambiguous input always
* returns all plausible candidates rather than picking one.
*
* Run: node invite-match.mjs < invite.txt (JSON to stdout)
* node invite-match.mjs --file invite.txt
* echo "..." | node invite-match.mjs --summary
* node invite-match.mjs --self-test
*
* Issue #1495 — github.com/santifer/career-ops
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
const CAREER_OPS = dirname(fileURLToPath(import.meta.url));
const APPS_FILE = existsSync(join(CAREER_OPS, 'data/applications.md'))
? join(CAREER_OPS, 'data/applications.md')
: join(CAREER_OPS, 'applications.md');
// --- CLI args ---
const args = process.argv.slice(2);
const summaryMode = args.includes('--summary');
const selfTestMode = args.includes('--self-test');
const fileIdx = args.indexOf('--file');
// Treat a following recognized flag (e.g. `--file --summary`) the same as a
// missing value — otherwise it's silently accepted as the path and produces
// a confusing "file not found: --summary" instead of the clearer error below.
if (fileIdx !== -1 && (args[fileIdx + 1] === undefined || args[fileIdx + 1].startsWith('--'))) {
console.error('invite-match: --file requires a path argument');
process.exit(1);
}
const filePathArg = fileIdx !== -1 ? args[fileIdx + 1] : null;
// Statuses ranked above others when disambiguating same-company candidates —
// an active application is a far more likely invite match than one already
// rejected or discarded, even if the rejected row is textually a closer date.
const STATUS_PRIORITY = {
interview: 0,
responded: 1,
applied: 2,
evaluated: 3,
offer: 4,
rejected: 5,
discarded: 6,
skip: 7,
};
function normalizeStatusKey(status) {
return String(status ?? '')
.replace(/\*\*/g, '')
.replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '')
.trim()
.toLowerCase();
}
// True legal-entity suffixes, stripped repeatedly (chained) since a name can
// legitimately carry more than one ("Acme Holdings Inc." → "acme holdings").
// These are unambiguous enough that removing several in a row is safe.
const LEGAL_SUFFIXES = [
'incorporated', 'inc', 'corporation', 'corp', 'company', 'co',
'limited', 'ltd', 'llc', 'llp', 'lp', 'plc',
];
// Generic business-descriptor words that vary between how a recruiter signs
// an email and how the tracker recorded the company, but are common enough
// as substantive parts of a name (e.g. "Data Solutions" vs "Data Corp") that
// chaining their removal risks collapsing two different companies to the
// same key. Stripped at most once, and only after legal suffixes are gone —
// never chained with each other or with LEGAL_SUFFIXES.
const GENERIC_DESCRIPTORS = [
'group', 'holdings', 'technologies', 'technology', 'solutions',
'canada', 'international',
];
/**
* Normalize a company name for matching: lowercase, strip punctuation and
* parentheticals, collapse whitespace, chain-strip trailing legal-entity
* suffixes (so "Acme Technologies Inc." reduces to "acme technologies"),
* then strip at most one trailing generic descriptor word. Deliberately
* stricter than dedup-tracker.mjs's normalizeCompany (which only lowercases
* and strips punctuation): invite emails quote company names more loosely
* than tracker rows quote each other, so matching across the two sources
* needs the extra suffix-stripping that same-source dedup does not.
*
* Generic descriptors are deliberately stripped only once (not chained) and
* only after legal suffixes, so two distinct companies that happen to both
* end in a generic word (e.g. "Data Solutions" vs "Data Corp") don't
* collapse to the same "data" key — see issue discussion on PR #1497.
*
* @param {string} name
* @returns {string}
*/
export function normalizeCompanyName(name) {
let key = String(name ?? '')
.toLowerCase()
.replace(/\([^)]*\)/g, ' ')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
let changed = true;
while (changed) {
changed = false;
for (const suffix of LEGAL_SUFFIXES) {
const re = new RegExp(`\\s${suffix}$`);
if (re.test(key)) {
key = key.replace(re, '').trim();
changed = true;
}
}
}
for (const word of GENERIC_DESCRIPTORS) {
const re = new RegExp(`\\s${word}$`);
if (re.test(key)) {
key = key.replace(re, '').trim();
break;
}
}
return key;
}
/**
* Token-overlap similarity between two normalized company-name strings.
* Returns 1 for an exact match, otherwise the fraction of the shorter name's
* tokens found in the longer name (order-independent), and 0 when there is
* no overlap at all. Deliberately simple — this is a "does this look like
* the same company" check, not a general string-distance metric.
*
* @param {string} a - Already-normalized name.
* @param {string} b - Already-normalized name.
* @returns {number} 0..1
*/
export function companySimilarity(a, b) {
if (!a || !b) return 0;
if (a === b) return 1;
const tokensA = a.split(' ').filter(Boolean);
const tokensB = b.split(' ').filter(Boolean);
if (tokensA.length === 0 || tokensB.length === 0) return 0;
const [shorter, longer] = tokensA.length <= tokensB.length ? [tokensA, tokensB] : [tokensB, tokensA];
const longerSet = new Set(longer);
const overlap = shorter.filter(t => longerSet.has(t)).length;
if (overlap === 0) return 0;
// Dice coefficient (2 * overlap / total tokens), not overlap/shorter-length:
// a full-containment match ("acme" inside "acme corp") still scores below
// an exact match, so when the tracker has both an exact-name row and a
// longer-name row for the same company, the exact match ranks first.
return (2 * overlap) / (tokensA.length + tokensB.length);
}
// --- Extract signals from invite text ---
// Matches the first "Company: X" / "at X" / "with X" style line, and falls
// back to the invite subject-style first line otherwise. Invite emails vary
// too much for one regex to be authoritative, so this is a best-effort
// extraction — the fuzzy match against the tracker is what actually decides
// the result, not this heuristic alone.
const COMPANY_LINE_PATTERNS = [
/(?:^|\n)\s*company\s*[:\-]\s*(.+)/i,
/interview(?:ing)?\s+(?:with|at)\s+([A-Z][\w.,&' -]{1,60}?)(?:[.,\n]|\s+for\s|\s+regarding\s|$)/i,
/(?:phone screen|screening|interview)\s*[-–—:]\s*([A-Z][\w.,&' -]{1,60}?)(?:\s+opportunity)?(?:[.,\n]|$)/i,
/schedule your (?:phone screen|interview)\s*(?:[-–—:]\s*)?([A-Z][\w.,&' -]{1,60}?)\s*opportunity/i,
];
/**
* Best-effort extraction of the company name from raw invite email text.
* Tries a handful of common invite phrasings; returns null if nothing
* plausible is found (caller should surface that to the user rather than
* guessing further).
*
* @param {string} text - Raw pasted invite email text.
* @returns {string|null}
*/
export function extractCompany(text) {
if (!text) return null;
for (const pattern of COMPANY_LINE_PATTERNS) {
const m = text.match(pattern);
if (m && m[1]) {
const candidate = m[1].trim().replace(/[.,;:]+$/, '');
if (candidate.length >= 2 && candidate.length <= 60) return candidate;
}
}
return null;
}
/**
* Best-effort extraction of a date mentioned in the invite (interview date,
* not necessarily the email send date). Only matches unambiguous ISO or
* "Month D, YYYY" forms — anything else is left for the human to read.
*
* @param {string} text
* @returns {string|null} YYYY-MM-DD or null.
*/
export function extractDate(text) {
if (!text) return null;
const iso = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
if (iso) return iso[0];
const months = 'January|February|March|April|May|June|July|August|September|October|November|December';
const named = text.match(new RegExp(`\\b(${months})\\s+(\\d{1,2}),?\\s+(\\d{4})\\b`, 'i'));
if (named) {
const monthIdx = new Date(`${named[1]} 1, 2000`).getMonth() + 1;
const day = String(named[2]).padStart(2, '0');
const month = String(monthIdx).padStart(2, '0');
return `${named[3]}-${month}-${day}`;
}
return null;
}
/**
* Best-effort extraction of a req/job-ID-looking token (e.g. "R260013984",
* "Req 32807", "Job ID: 43683", "JR12352") — present in a minority of
* invites but a strong disambiguator when it is, since it can be cross-
* checked against the tracker's notes column.
*
* @param {string} text
* @returns {string|null}
*/
export function extractReqId(text) {
if (!text) return null;
const m = text.match(/\b(?:req(?:uisition)?\.?\s*(?:id)?[:\s#]*|job\s*id[:\s#]*)([A-Z]{0,3}\d{3,10})\b/i)
|| text.match(/\b([A-Z]{1,3}\d{5,10})\b/);
return m ? m[1] : null;
}
// --- Tracker loading ---
function loadTracker(appsFile = APPS_FILE) {
if (!existsSync(appsFile)) return [];
const content = readFileSync(appsFile, 'utf-8');
const lines = content.split('\n');
const colmap = resolveColumns(lines);
const entries = [];
for (const line of lines) {
const row = parseTrackerRow(line, colmap);
if (row) entries.push(row);
}
return entries;
}
/**
* Core matcher: given extracted invite signals and a list of tracker rows,
* return ranked candidates. Exported so tests can drive it directly against
* fixture rows without touching the real tracker file.
*
* @param {{company: string|null, date: string|null, reqId: string|null}} signals
* @param {Array<object>} trackerRows - Rows from parseTrackerRow().
* @returns {Array<object>} Ranked candidates, highest confidence first.
*/
export function matchInvite(signals, trackerRows) {
if (!signals || !signals.company || !Array.isArray(trackerRows)) return [];
const targetKey = normalizeCompanyName(signals.company);
if (!targetKey) return [];
const scored = [];
for (const row of trackerRows) {
const rowKey = normalizeCompanyName(row.company);
const nameScore = companySimilarity(targetKey, rowKey);
if (nameScore <= 0) continue;
let confidence = nameScore;
// A req/job ID appearing in the row's notes is a near-certain match —
// boost it above any name-only match (including another exact name
// match without the req ID). Compared case-insensitively: the invite
// and the notes may case the same ID differently ("jr12352" vs
// "JR12352"). matchConfidence is a ranking score, not a probability,
// so it's intentionally allowed to exceed 1 here.
if (signals.reqId && row.notes
&& row.notes.toLowerCase().includes(signals.reqId.toLowerCase())) {
confidence += 0.5;
}
// Prefer rows in an active/actionable status over closed-out ones when
// the same company has multiple tracker entries.
const statusRank = STATUS_PRIORITY[normalizeStatusKey(row.status)] ?? 8;
confidence += (7 - Math.min(statusRank, 7)) * 0.01; // tiny tiebreaker, never dominates nameScore
scored.push({
appNumber: row.num,
company: row.company,
role: row.role,
status: row.status,
date: row.date,
matchConfidence: Math.round(confidence * 1000) / 1000,
});
}
scored.sort((a, b) => b.matchConfidence - a.matchConfidence);
return scored;
}
/**
* End-to-end: parse invite text, load the tracker, return ranked candidates
* plus the signals that were extracted (so the caller/CLI can show what was
* understood from the email, not just the result).
*
* @param {string} text - Raw invite email text.
* @param {Array<object>} [trackerRows] - Injectable for tests; defaults to loadTracker().
* @returns {{signals: object, candidates: Array<object>}}
*/
export function analyzeInvite(text, trackerRows = null) {
const signals = {
company: extractCompany(text),
date: extractDate(text),
reqId: extractReqId(text),
};
const rows = trackerRows ?? loadTracker();
const candidates = matchInvite(signals, rows);
return { signals, candidates };
}
// --- Summary mode ---
function printSummary(result) {
console.log(`\n${'='.repeat(70)}`);
console.log(' Interview Invite Matcher — career-ops');
console.log(`${'='.repeat(70)}\n`);
console.log(` Extracted company: ${result.signals.company || '(not found)'}`);
console.log(` Extracted date: ${result.signals.date || '(not found)'}`);
console.log(` Extracted req ID: ${result.signals.reqId || '(not found)'}\n`);
if (!result.signals.company) {
console.log(' Could not find a company name in the invite text — paste more context or check manually.\n');
return;
}
if (result.candidates.length === 0) {
console.log(' No matching tracker entries found for this company.\n');
return;
}
console.log(' ' + '#'.padEnd(6) + 'Company'.padEnd(20) + 'Role'.padEnd(34) + 'Status'.padEnd(12) + 'Confidence');
console.log(' ' + '-'.repeat(88));
for (const c of result.candidates.slice(0, 5)) {
console.log(
' ' +
String(c.appNumber).padEnd(6) +
c.company.substring(0, 18).padEnd(20) +
c.role.substring(0, 32).padEnd(34) +
c.status.padEnd(12) +
String(c.matchConfidence)
);
}
console.log('');
}
// --- Self-test ---
function runSelfTest() {
let pass = 0;
let fail = 0;
const check = (cond, label) => {
if (cond) { pass += 1; } else { fail += 1; console.error(` FAIL: ${label}`); }
};
// --- normalizeCompanyName ---
check(normalizeCompanyName('Acme Corp.') === 'acme', 'strips "Corp." suffix');
check(normalizeCompanyName('Acme Technologies Inc.') === 'acme', 'strips chained suffixes');
check(normalizeCompanyName('Acme (Example Group)') === 'acme', 'drops parenthetical branding');
check(normalizeCompanyName('Acme & Co') === normalizeCompanyName('Acme and Co'), '"&" normalizes the same as "and"');
check(normalizeCompanyName(' ACME ') === 'acme', 'trims and lowercases whitespace-padded input');
// --- companySimilarity ---
check(companySimilarity('acme', 'acme') === 1, 'identical strings score 1');
check(companySimilarity('acme example', 'acme') > 0.5, 'substring containment scores high');
check(companySimilarity('acme', 'globex') === 0, 'unrelated names score 0');
check(companySimilarity('', 'acme') === 0, 'empty string never matches');
// --- extractCompany ---
check(extractCompany('Company: Example Industries\nRole: Analyst') === 'Example Industries', 'extracts from "Company:" line');
check(extractCompany('Schedule Your Phone Screen Acme Opportunity') === 'Acme', 'extracts from generic "Schedule Your Phone Screen" subject');
check(extractCompany('Looking forward to interviewing with Example Corp for the role.') === 'Example Corp', 'extracts from "interviewing with X" phrasing');
check(extractCompany('no company signal here at all') === null, 'returns null when nothing plausible is found');
// --- extractDate ---
check(extractDate('Interview scheduled for 2026-07-09 at 4pm') === '2026-07-09', 'extracts ISO date');
check(extractDate('See you on July 9, 2026') === '2026-07-09', 'extracts named-month date');
check(extractDate('no date mentioned') === null, 'returns null when no date is present');
// --- extractReqId ---
check(extractReqId('Req ID: R260013984') === 'R260013984', 'extracts "Req ID:" token');
check(extractReqId('Job ID: 43683') === '43683', 'extracts "Job ID:" token');
check(extractReqId('no id here') === null, 'returns null when no req-like token is present');
// --- matchInvite (fixture rows, no real tracker data) ---
const fixtureRows = [
{ num: 101, company: 'Example Industries', role: 'Training Coordinator', status: 'Applied', date: '2026-06-01', notes: 'Req EX9001' },
{ num: 102, company: 'Example Industries', role: 'HR Generalist', status: 'Rejected', date: '2026-05-10', notes: 'Rejected 2026-05-20' },
{ num: 103, company: 'Acme Corp', role: 'Program Coordinator', status: 'Interview', date: '2026-06-15', notes: '' },
{ num: 104, company: 'Globex LLC', role: 'Analyst', status: 'Applied', date: '2026-06-20', notes: '' },
];
const noSignal = matchInvite({ company: null, date: null, reqId: null }, fixtureRows);
check(noSignal.length === 0, 'no company signal → no candidates');
const acmeMatch = matchInvite({ company: 'Acme Corp.', date: null, reqId: null }, fixtureRows);
check(acmeMatch.length === 1 && acmeMatch[0].appNumber === 103, 'matches "Acme Corp." to the Acme Corp tracker row despite suffix punctuation');
const exampleMatch = matchInvite({ company: 'Example Industries', date: null, reqId: null }, fixtureRows);
check(exampleMatch.length === 2, 'same company with multiple tracker rows returns all candidates, not just one');
check(exampleMatch[0].appNumber === 101, 'active (Applied) row ranks above the Rejected row for the same company when name-match is tied');
const reqBoosted = matchInvite({ company: 'Example Industries', date: null, reqId: 'EX9001' }, fixtureRows);
check(reqBoosted[0].appNumber === 101 && reqBoosted[0].matchConfidence > exampleMatch[0].matchConfidence, 'a req ID found in notes boosts that candidate\'s confidence');
const noMatch = matchInvite({ company: 'Totally Unrelated Co', date: null, reqId: null }, fixtureRows);
check(noMatch.length === 0, 'unrelated company name returns no candidates');
// --- analyzeInvite (end-to-end with injected rows, no file I/O) ---
const fullText = 'Schedule Your Phone Screen Acme Opportunity\nInterview scheduled for 2026-07-09.';
const result = analyzeInvite(fullText, fixtureRows);
check(result.signals.company === 'Acme', 'analyzeInvite extracts company end-to-end');
check(result.signals.date === '2026-07-09', 'analyzeInvite extracts date end-to-end');
check(result.candidates.length === 1 && result.candidates[0].appNumber === 103, 'analyzeInvite returns the matched candidate end-to-end');
console.log(`\n invite-match self-test: ${pass} passed, ${fail} failed\n`);
process.exit(fail > 0 ? 1 : 0);
}
// --- Run (CLI only; guarded so the module is safely importable for tests) ---
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (selfTestMode) {
runSelfTest();
} else {
let text;
if (filePathArg) {
if (!existsSync(filePathArg)) {
console.error(`invite-match: file not found: ${filePathArg}`);
process.exit(1);
}
text = readFileSync(filePathArg, 'utf-8');
} else {
text = readFileSync(0, 'utf-8'); // stdin
}
const result = analyzeInvite(text);
if (summaryMode) {
printSummary(result);
} else {
console.log(JSON.stringify(result, null, 2));
}
}
}