init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,863 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* analyze-patterns.mjs — Rejection Pattern Detector for career-ops
|
||||
*
|
||||
* Parses applications.md + all linked reports, extracts dimensions
|
||||
* (archetype, seniority, remote, gaps, scores), classifies outcomes,
|
||||
* and outputs structured JSON with actionable patterns.
|
||||
*
|
||||
* Run: node analyze-patterns.mjs (JSON to stdout)
|
||||
* node analyze-patterns.mjs --summary (human-readable table)
|
||||
* node analyze-patterns.mjs --min-threshold 3
|
||||
* node analyze-patterns.mjs --min-vendor-n 8 (per-vendor sample floor)
|
||||
* node analyze-patterns.mjs --self-test
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { load as yamlLoad } from 'js-yaml';
|
||||
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');
|
||||
const REPORTS_DIR = join(CAREER_OPS, 'reports');
|
||||
|
||||
const MACHINE_SUMMARY_FIELDS = new Set([
|
||||
'company',
|
||||
'role',
|
||||
'score',
|
||||
'legitimacy_tier',
|
||||
'archetype',
|
||||
'final_decision',
|
||||
'hard_stops',
|
||||
'soft_gaps',
|
||||
'top_strengths',
|
||||
'risk_level',
|
||||
'confidence',
|
||||
'next_action',
|
||||
// Optional context fields accepted for future reports.
|
||||
'domain',
|
||||
'seniority',
|
||||
'remote',
|
||||
'team_size',
|
||||
]);
|
||||
|
||||
// --- CLI args ---
|
||||
const args = process.argv.slice(2);
|
||||
const summaryMode = args.includes('--summary');
|
||||
const minThresholdIdx = args.indexOf('--min-threshold');
|
||||
const MIN_THRESHOLD = minThresholdIdx !== -1 && args[minThresholdIdx + 1] !== undefined
|
||||
? (Number.isNaN(parseInt(args[minThresholdIdx + 1])) ? 5 : parseInt(args[minThresholdIdx + 1]))
|
||||
: 5;
|
||||
|
||||
// Minimum per-vendor sample before a channel-yield recommendation fires. Kept
|
||||
// modest (small trackers) but high enough that one unlucky bucket isn't a claim.
|
||||
const minVendorNIdx = args.indexOf('--min-vendor-n');
|
||||
const MIN_VENDOR_N = (() => {
|
||||
if (minVendorNIdx === -1 || args[minVendorNIdx + 1] === undefined) return 8;
|
||||
const n = parseInt(args[minVendorNIdx + 1], 10);
|
||||
// Reject 0/negative: a floor of 0 makes sufficientSample always true and
|
||||
// silently defeats the "don't claim on noise" guard the whole feature rests on.
|
||||
return Number.isNaN(n) || n < 1 ? 8 : n;
|
||||
})();
|
||||
|
||||
// --- Status normalization (mirrors verify-pipeline.mjs) ---
|
||||
const ALIASES = {
|
||||
'evaluada': 'evaluated', 'condicional': 'evaluated', 'hold': 'evaluated',
|
||||
'evaluar': 'evaluated', 'verificar': 'evaluated',
|
||||
'aplicado': 'applied', 'enviada': 'applied', 'aplicada': 'applied',
|
||||
'applied': 'applied', 'sent': 'applied',
|
||||
'respondido': 'responded',
|
||||
'entrevista': 'interview',
|
||||
'oferta': 'offer',
|
||||
'rechazado': 'rejected', 'rechazada': 'rejected',
|
||||
'descartado': 'discarded', 'descartada': 'discarded',
|
||||
'cerrada': 'discarded', 'cancelada': 'discarded',
|
||||
'no aplicar': 'skip', 'no_aplicar': 'skip', 'monitor': 'skip', 'geo blocker': 'skip',
|
||||
};
|
||||
|
||||
function normalizeStatus(raw) {
|
||||
const clean = raw.replace(/\*\*/g, '').trim().toLowerCase()
|
||||
.replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim();
|
||||
return ALIASES[clean] || clean;
|
||||
}
|
||||
|
||||
function classifyOutcome(status) {
|
||||
const s = normalizeStatus(status);
|
||||
if (['interview', 'offer', 'responded', 'applied'].includes(s)) return 'positive';
|
||||
if (['rejected', 'discarded'].includes(s)) return 'negative';
|
||||
if (['skip'].includes(s)) return 'self_filtered';
|
||||
return 'pending'; // evaluated
|
||||
}
|
||||
|
||||
function normalizeList(value) {
|
||||
if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean);
|
||||
if (value === null || value === undefined || value === '') return [];
|
||||
if (typeof value === 'object') return [];
|
||||
return [String(value).trim()].filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeScalar(value) {
|
||||
if (typeof value === 'string') return value.trim() || null;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMachineSummary(content) {
|
||||
const fenceMatch = content.match(/##\s*Machine Summary\s*\n+```(?:yaml|yml|json)?\s*\n([\s\S]*?)\n```/i);
|
||||
if (!fenceMatch) return null;
|
||||
|
||||
const raw = fenceMatch[1].trim();
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = yamlLoad(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => MACHINE_SUMMARY_FIELDS.has(key))
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function runSelfTest() {
|
||||
const summary = parseMachineSummary(`
|
||||
## Machine Summary
|
||||
|
||||
\`\`\`yaml
|
||||
company: "Acme"
|
||||
role: "Staff AI Engineer"
|
||||
score: 4.4
|
||||
legitimacy_tier: "High Confidence"
|
||||
archetype: "AI Platform / LLMOps Engineer"
|
||||
final_decision: "Apply"
|
||||
hard_stops: []
|
||||
soft_gaps:
|
||||
- "No direct healthcare domain experience"
|
||||
top_strengths:
|
||||
- "Production evaluation pipelines"
|
||||
risk_level: "Medium"
|
||||
confidence: "High"
|
||||
next_action: "Follow up on ticket #42 with tailored CV"
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
const failures = [];
|
||||
if (!summary) failures.push('summary was not parsed');
|
||||
if (summary?.score !== 4.4) failures.push('numeric score was not parsed');
|
||||
if (!Array.isArray(summary?.hard_stops) || summary.hard_stops.length !== 0) failures.push('empty list was not parsed');
|
||||
if (summary?.soft_gaps?.[0] !== 'No direct healthcare domain experience') failures.push('list item was not parsed');
|
||||
if (summary?.next_action !== 'Follow up on ticket #42 with tailored CV') failures.push('hash-containing scalar field was not parsed');
|
||||
|
||||
// Vendor detection (community ATS only; white-labeled → null)
|
||||
const vendorCases = [
|
||||
['https://boards.greenhouse.io/acme/jobs/12345', 'greenhouse'],
|
||||
['https://job-boards.eu.greenhouse.io/acme/jobs/9', 'greenhouse'],
|
||||
['https://jobs.lever.co/acme/abc-def', 'lever'],
|
||||
['https://jobs.ashbyhq.com/acme/uuid', 'ashby'],
|
||||
['https://acme.wd1.myworkdayjobs.com/en-US/careers/job/R-1', 'workday'],
|
||||
['https://careers.icims.com/jobs/9/x', null],
|
||||
['https://jobs.dayforcehcm.com/en-US/co/CANDIDATEPORTAL/jobs/1', null],
|
||||
['not a url', null],
|
||||
['', null],
|
||||
[null, null],
|
||||
];
|
||||
for (const [url, expected] of vendorCases) {
|
||||
const got = detectVendor(url);
|
||||
if (got !== expected) failures.push(`detectVendor(${JSON.stringify(url)}) → ${JSON.stringify(got)}, expected ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`analyze-patterns self-test failed: ${failures.join('; ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('analyze-patterns self-test OK (Machine Summary parser + vendor detection)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- Parse applications.md ---
|
||||
function parseTracker() {
|
||||
if (!existsSync(APPS_FILE)) return [];
|
||||
const content = readFileSync(APPS_FILE, '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;
|
||||
}
|
||||
|
||||
// --- Parse a single report file ---
|
||||
function parseReport(reportPath) {
|
||||
if (!existsSync(reportPath)) return null;
|
||||
const content = readFileSync(reportPath, 'utf-8');
|
||||
const report = {
|
||||
company: null,
|
||||
role: null,
|
||||
url: null,
|
||||
archetype: null,
|
||||
legitimacyTier: null,
|
||||
finalDecision: null,
|
||||
seniority: null,
|
||||
remote: null,
|
||||
teamSize: null,
|
||||
comp: null,
|
||||
domain: null,
|
||||
riskLevel: null,
|
||||
confidence: null,
|
||||
nextAction: null,
|
||||
topStrengths: [],
|
||||
scores: {},
|
||||
gaps: [],
|
||||
};
|
||||
|
||||
const machineSummary = parseMachineSummary(content);
|
||||
if (machineSummary) {
|
||||
report.machineSummary = machineSummary;
|
||||
report.company = normalizeScalar(machineSummary.company) || report.company;
|
||||
report.role = normalizeScalar(machineSummary.role) || report.role;
|
||||
report.archetype = normalizeScalar(machineSummary.archetype) || report.archetype;
|
||||
report.legitimacyTier = normalizeScalar(machineSummary.legitimacy_tier) || report.legitimacyTier;
|
||||
report.finalDecision = normalizeScalar(machineSummary.final_decision) || report.finalDecision;
|
||||
report.domain = normalizeScalar(machineSummary.domain) || report.domain;
|
||||
report.seniority = normalizeScalar(machineSummary.seniority) || report.seniority;
|
||||
report.remote = normalizeScalar(machineSummary.remote) || report.remote;
|
||||
report.teamSize = normalizeScalar(machineSummary.team_size) || report.teamSize;
|
||||
report.riskLevel = normalizeScalar(machineSummary.risk_level) || report.riskLevel;
|
||||
report.confidence = normalizeScalar(machineSummary.confidence) || report.confidence;
|
||||
report.nextAction = normalizeScalar(machineSummary.next_action) || report.nextAction;
|
||||
report.topStrengths = normalizeList(machineSummary.top_strengths);
|
||||
|
||||
if (typeof machineSummary.score === 'number') {
|
||||
report.scores.global = machineSummary.score;
|
||||
}
|
||||
|
||||
for (const hardStop of normalizeList(machineSummary.hard_stops)) {
|
||||
report.gaps.push({ description: hardStop, severity: 'hard stop', mitigation: '' });
|
||||
}
|
||||
for (const softGap of normalizeList(machineSummary.soft_gaps)) {
|
||||
report.gaps.push({ description: softGap, severity: 'soft gap', mitigation: '' });
|
||||
}
|
||||
}
|
||||
|
||||
// Strip bold markers for easier matching
|
||||
const plain = content.replace(/\*\*/g, '');
|
||||
|
||||
// Extract Block A table (Role Summary) — works with both EN and ES headers
|
||||
// Archetype cell may be labeled "Archetype", "Arquetipo", or "Detected archetype" (drift from EN translation).
|
||||
const blockARegex = /\|\s*(?:Detected\s+)?(?:Archetype|Arquetipo)\s*\|\s*(.*?)\s*\|/i;
|
||||
const seniorityRegex = /\|\s*(?:Seniority|Nivel|Level)\s*\|\s*(.*?)\s*\|/i;
|
||||
const remoteRegex = /\|\s*(?:Remote|Remoto|Location)\s*\|\s*(.*?)\s*\|/i;
|
||||
const teamRegex = /\|\s*(?:Team|Team size|Equipo)\s*\|\s*(.*?)\s*\|/i;
|
||||
const compRegex = /\|\s*(?:Comp|Salary|Salario|Listed salary)\s*\|\s*(.*?)\s*\|/i;
|
||||
const domainRegex = /\|\s*(?:Domain|Dominio|Industry)\s*\|\s*(.*?)\s*\|/i;
|
||||
|
||||
// Fallback: report header field `Archetype: ...` or `Arquetipo: ...` (newer reports use this).
|
||||
const headerArchRegex = /^(?:Archetype|Arquetipo):\s*(.+?)$/im;
|
||||
|
||||
// Report header carries `**URL:**` between Score and PDF (see CLAUDE.md /
|
||||
// Pipeline Integrity). Capture the first http(s) URL on that line for vendor
|
||||
// detection; reports predating the field simply leave url null (→ unknown bucket).
|
||||
const urlMatch = plain.match(/^URL:\s*(https?:\/\/\S+)/im);
|
||||
if (urlMatch && !report.url) report.url = urlMatch[1].trim().replace(/[)>\].,]+$/, '');
|
||||
|
||||
const archMatch = plain.match(blockARegex) || plain.match(headerArchRegex);
|
||||
if (archMatch && !report.archetype) report.archetype = archMatch[1].trim();
|
||||
|
||||
const senMatch = plain.match(seniorityRegex);
|
||||
if (senMatch && !report.seniority) report.seniority = senMatch[1].trim();
|
||||
|
||||
const remMatch = plain.match(remoteRegex);
|
||||
if (remMatch && !report.remote) report.remote = remMatch[1].trim();
|
||||
|
||||
const teamMatch = plain.match(teamRegex);
|
||||
if (teamMatch && !report.teamSize) report.teamSize = teamMatch[1].trim();
|
||||
|
||||
const compMatch = plain.match(compRegex);
|
||||
if (compMatch && !report.comp) report.comp = compMatch[1].trim();
|
||||
|
||||
const domainMatch = plain.match(domainRegex);
|
||||
if (domainMatch && !report.domain) report.domain = domainMatch[1].trim();
|
||||
|
||||
// Extract scoring table — look for table with "Global" row (using plain, bold already stripped)
|
||||
const scoreRegex = /\|\s*(?:CV Match|Match con CV)\s*\|\s*([\d.]+)\/5\s*\|/i;
|
||||
const northStarRegex = /\|\s*(?:North Star)\s*\|\s*([\d.]+)\/5\s*\|/i;
|
||||
const compScoreRegex = /\|\s*(?:Comp)\s*\|\s*([\d.]+)\/5\s*\|/i;
|
||||
const culturalRegex = /\|\s*(?:Cultural signals|Cultural)\s*\|\s*([\d.]+)\/5\s*\|/i;
|
||||
const redFlagsRegex = /\|\s*(?:Red flags)\s*\|\s*([-+]?[\d.]+)\s*\|/i;
|
||||
const globalRegex = /\|\s*(?:Global)\s*\|\s*([\d.]+)\/5\s*\|/i;
|
||||
|
||||
const cvScoreMatch = plain.match(scoreRegex);
|
||||
if (cvScoreMatch && report.scores.cvMatch === undefined) report.scores.cvMatch = parseFloat(cvScoreMatch[1]);
|
||||
|
||||
const nsMatch = plain.match(northStarRegex);
|
||||
if (nsMatch && report.scores.northStar === undefined) report.scores.northStar = parseFloat(nsMatch[1]);
|
||||
|
||||
const csMatch = plain.match(compScoreRegex);
|
||||
if (csMatch && report.scores.comp === undefined) report.scores.comp = parseFloat(csMatch[1]);
|
||||
|
||||
const culMatch = plain.match(culturalRegex);
|
||||
if (culMatch && report.scores.cultural === undefined) report.scores.cultural = parseFloat(culMatch[1]);
|
||||
|
||||
const rfMatch = plain.match(redFlagsRegex);
|
||||
if (rfMatch && report.scores.redFlags === undefined) report.scores.redFlags = parseFloat(rfMatch[1]);
|
||||
|
||||
const glMatch = plain.match(globalRegex);
|
||||
if (glMatch && report.scores.global === undefined) report.scores.global = parseFloat(glMatch[1]);
|
||||
|
||||
// Extract gaps table
|
||||
const gapTableRegex = /\|\s*Gap\s*\|\s*Severity\s*\|.*?\n\|[-|\s]+\n([\s\S]*?)(?:\n\n|\n##|\n\*\*|$)/i;
|
||||
const gapTableMatch = content.match(gapTableRegex);
|
||||
if (gapTableMatch) {
|
||||
const gapRows = gapTableMatch[1].split('\n').filter(r => r.startsWith('|'));
|
||||
for (const row of gapRows) {
|
||||
const cols = row.split('|').map(s => s.trim()).filter(Boolean);
|
||||
if (cols.length >= 2) {
|
||||
const duplicate = report.gaps.some(g => g.description.toLowerCase() === cols[0].toLowerCase());
|
||||
if (!duplicate) {
|
||||
report.gaps.push({
|
||||
description: cols[0],
|
||||
severity: cols[1].toLowerCase(),
|
||||
mitigation: cols[2] || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// --- Classify remote policy into buckets ---
|
||||
function classifyRemote(raw) {
|
||||
if (!raw) return 'unknown';
|
||||
const lower = raw.toLowerCase();
|
||||
// Order matters: check geo-restricted before general remote
|
||||
if (/\b(us[- ]?only|canada[- ]?only|residents only|usa only|us residents|canada residents)\b/.test(lower)) return 'geo-restricted';
|
||||
if (/\bargentina\s+remote\s+only\b/.test(lower)) return 'geo-restricted';
|
||||
if (/\b(hybrid|on-?site|office|columbus|cape town|relocat)\b/.test(lower)) return 'hybrid/onsite';
|
||||
if (/\b(global|anywhere|worldwide|no restrict|70\+|work from anywhere)\b/.test(lower)) return 'global remote';
|
||||
if (/\b(remote|latam|americas|brazil|fully remote)\b/.test(lower)) return 'regional remote';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// --- Detect ATS vendor from a posting URL ---
|
||||
// Host-only match, deliberately looser than liveness-api.mjs's resolveAtsApi()
|
||||
// (which needs the full posting path to build an API URL) — a tracker report's
|
||||
// URL may point at a board/careers page, not a canonical posting.
|
||||
//
|
||||
// SCOPE (intentional): only community ATS with clean, public URL fingerprints —
|
||||
// Greenhouse, Lever, Ashby, Workday. White-labeled ATS (iCIMS/UKG/Dayforce) are
|
||||
// NOT detectable from the URL alone and are deferred until the community adds a
|
||||
// reliable signal (e.g. confirmation-email domain). Undetected → 'unknown'.
|
||||
const VENDOR_HOST_PATTERNS = [
|
||||
{ id: 'greenhouse', test: (h) => /(^|\.)greenhouse\.io$/.test(h) },
|
||||
{ id: 'lever', test: (h) => h === 'jobs.lever.co' || h.endsWith('.lever.co') },
|
||||
{ id: 'ashby', test: (h) => h === 'jobs.ashbyhq.com' || h.endsWith('.ashbyhq.com') },
|
||||
{ id: 'workday', test: (h) => h.endsWith('.myworkdayjobs.com') || h.endsWith('.myworkdaysite.com') },
|
||||
];
|
||||
|
||||
function detectVendor(rawUrl) {
|
||||
if (!rawUrl || typeof rawUrl !== 'string') return null;
|
||||
let u;
|
||||
try { u = new URL(rawUrl.trim()); } catch { return null; }
|
||||
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
||||
const host = u.hostname.toLowerCase();
|
||||
for (const v of VENDOR_HOST_PATTERNS) if (v.test(host)) return v.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Classify company size ---
|
||||
function classifyCompanySize(teamSize) {
|
||||
if (!teamSize) return 'unknown';
|
||||
const lower = teamSize.toLowerCase();
|
||||
// Extract numbers
|
||||
const nums = lower.match(/[\d,]+/g);
|
||||
if (nums) {
|
||||
const max = Math.max(...nums.map(n => parseInt(n.replace(/,/g, ''))));
|
||||
if (max <= 50) return 'startup';
|
||||
if (max <= 500) return 'scaleup';
|
||||
return 'enterprise';
|
||||
}
|
||||
if (/\b(small|elite|tiny|founding)\b/.test(lower)) return 'startup';
|
||||
if (/\b(large|enterprise|global)\b/.test(lower)) return 'enterprise';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// --- Extract hard blocker keywords from gaps ---
|
||||
function extractBlockerType(gap) {
|
||||
const desc = gap.description.toLowerCase();
|
||||
const sev = gap.severity.toLowerCase();
|
||||
if (sev.includes('nice') || sev.includes('soft')) return null; // skip soft gaps
|
||||
if (/\b(residency|us[- ]only|canada|location|visa|geo|country|region)\b/.test(desc)) return 'geo-restriction';
|
||||
if (/\b(javascript|typescript|python|ruby|java|go|rust|node|react|angular|vue|django|flask|rails)\b/.test(desc)) return 'stack-mismatch';
|
||||
if (/\b(senior|staff|lead|principal|director|manager|head)\b/.test(desc)) return 'seniority-mismatch';
|
||||
if (/\b(hybrid|on-?site|office|relocat)\b/.test(desc)) return 'onsite-requirement';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// --- Main analysis ---
|
||||
function analyze() {
|
||||
const entries = parseTracker();
|
||||
|
||||
if (entries.length === 0) {
|
||||
return { error: 'No applications found in tracker.' };
|
||||
}
|
||||
|
||||
// Enrich entries with report data and classification
|
||||
const enriched = entries.map(e => {
|
||||
const reportMatch = e.report.match(/\]\(([^)]+)\)/);
|
||||
const reportPath = reportMatch ? join(CAREER_OPS, reportMatch[1]) : null;
|
||||
const reportData = reportPath ? parseReport(reportPath) : null;
|
||||
const outcome = classifyOutcome(e.status);
|
||||
const trackerScore = parseFloat(e.score);
|
||||
const score = Number.isFinite(trackerScore)
|
||||
? trackerScore
|
||||
: (Number.isFinite(reportData?.scores?.global) ? reportData.scores.global : 0);
|
||||
|
||||
// Fallback: if report didn't have Remote field, try the notes column
|
||||
const remoteSource = reportData?.remote || e.notes || '';
|
||||
const teamSource = reportData?.teamSize || '';
|
||||
|
||||
return {
|
||||
...e,
|
||||
normalizedStatus: normalizeStatus(e.status),
|
||||
outcome,
|
||||
score,
|
||||
report: reportData,
|
||||
remoteBucket: classifyRemote(remoteSource),
|
||||
companySize: classifyCompanySize(teamSource),
|
||||
vendor: detectVendor(reportData?.url),
|
||||
};
|
||||
});
|
||||
|
||||
// Count entries beyond "Evaluated"
|
||||
const beyondEvaluated = enriched.filter(e => e.normalizedStatus !== 'evaluated');
|
||||
if (beyondEvaluated.length < MIN_THRESHOLD) {
|
||||
return {
|
||||
error: `Not enough data: ${beyondEvaluated.length}/${MIN_THRESHOLD} applications beyond "Evaluated". Keep applying and come back later.`,
|
||||
current: beyondEvaluated.length,
|
||||
threshold: MIN_THRESHOLD,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Funnel ---
|
||||
const funnel = {};
|
||||
for (const e of enriched) {
|
||||
const s = e.normalizedStatus;
|
||||
funnel[s] = (funnel[s] || 0) + 1;
|
||||
}
|
||||
|
||||
// --- Score comparison by outcome ---
|
||||
const scoresByOutcome = { positive: [], negative: [], self_filtered: [], pending: [] };
|
||||
for (const e of enriched) {
|
||||
if (e.score > 0) scoresByOutcome[e.outcome].push(e.score);
|
||||
}
|
||||
|
||||
const scoreStats = (arr) => {
|
||||
if (arr.length === 0) return { avg: 0, min: 0, max: 0, count: 0 };
|
||||
const avg = arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
return {
|
||||
avg: Math.round(avg * 100) / 100,
|
||||
min: Math.min(...arr),
|
||||
max: Math.max(...arr),
|
||||
count: arr.length,
|
||||
};
|
||||
};
|
||||
|
||||
const scoreComparison = {
|
||||
positive: scoreStats(scoresByOutcome.positive),
|
||||
negative: scoreStats(scoresByOutcome.negative),
|
||||
self_filtered: scoreStats(scoresByOutcome.self_filtered),
|
||||
pending: scoreStats(scoresByOutcome.pending),
|
||||
};
|
||||
|
||||
// --- Archetype breakdown ---
|
||||
const archetypeMap = new Map();
|
||||
for (const e of enriched) {
|
||||
const arch = e.report?.archetype || 'Unknown';
|
||||
if (!archetypeMap.has(arch)) archetypeMap.set(arch, { total: 0, positive: 0, negative: 0, self_filtered: 0, pending: 0 });
|
||||
const entry = archetypeMap.get(arch);
|
||||
entry.total++;
|
||||
entry[e.outcome]++;
|
||||
}
|
||||
const archetypeBreakdown = [...archetypeMap.entries()].map(([archetype, data]) => ({
|
||||
archetype,
|
||||
...data,
|
||||
conversionRate: data.total > 0 ? Math.round((data.positive / data.total) * 100) : 0,
|
||||
})).sort((a, b) => b.total - a.total);
|
||||
|
||||
// --- Blocker analysis ---
|
||||
const blockerCounts = new Map();
|
||||
const totalWithGaps = enriched.filter(e => e.report?.gaps?.length > 0);
|
||||
for (const e of enriched) {
|
||||
if (!e.report?.gaps) continue;
|
||||
for (const gap of e.report.gaps) {
|
||||
const type = extractBlockerType(gap);
|
||||
if (!type) continue;
|
||||
blockerCounts.set(type, (blockerCounts.get(type) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const blockerAnalysis = [...blockerCounts.entries()]
|
||||
.map(([blocker, frequency]) => ({
|
||||
blocker,
|
||||
frequency,
|
||||
percentage: Math.round((frequency / enriched.length) * 100),
|
||||
}))
|
||||
.sort((a, b) => b.frequency - a.frequency);
|
||||
|
||||
// --- Remote policy breakdown ---
|
||||
const remoteMap = new Map();
|
||||
for (const e of enriched) {
|
||||
const policy = e.remoteBucket;
|
||||
if (!remoteMap.has(policy)) remoteMap.set(policy, { total: 0, positive: 0, negative: 0, self_filtered: 0, pending: 0 });
|
||||
const entry = remoteMap.get(policy);
|
||||
entry.total++;
|
||||
entry[e.outcome]++;
|
||||
}
|
||||
const remotePolicy = [...remoteMap.entries()].map(([policy, data]) => ({
|
||||
policy,
|
||||
...data,
|
||||
conversionRate: data.total > 0 ? Math.round((data.positive / data.total) * 100) : 0,
|
||||
})).sort((a, b) => b.total - a.total);
|
||||
|
||||
// --- Company size breakdown ---
|
||||
const sizeMap = new Map();
|
||||
for (const e of enriched) {
|
||||
const size = e.companySize;
|
||||
if (!sizeMap.has(size)) sizeMap.set(size, { total: 0, positive: 0, negative: 0, self_filtered: 0, pending: 0 });
|
||||
const entry = sizeMap.get(size);
|
||||
entry.total++;
|
||||
entry[e.outcome]++;
|
||||
}
|
||||
const companySizeBreakdown = [...sizeMap.entries()].map(([size, data]) => ({
|
||||
size,
|
||||
...data,
|
||||
conversionRate: data.total > 0 ? Math.round((data.positive / data.total) * 100) : 0,
|
||||
})).sort((a, b) => b.total - a.total);
|
||||
|
||||
// --- ATS vendor / channel analysis (algorithmic-monoculture aware) ---
|
||||
// Motivation: Bommasani et al., "Algorithmic Monocultures in Hiring" (FAccT
|
||||
// 2026, arXiv:2605.27371) — rejections routed through a shared screening
|
||||
// vendor are correlated, not independent. If a concentrated channel yields
|
||||
// nothing, feeding it the same profile has diminishing returns; the rational
|
||||
// move is to divert those companies to referral/direct contact.
|
||||
//
|
||||
// HONESTY: this reports CHANNEL YIELD, not discrimination. A single tracker
|
||||
// can't causally separate "the vendor's algorithm filters me" from "that
|
||||
// vendor skews toward a segment I fit poorly" — but "stop feeding a dead
|
||||
// channel, go around it" is rational under either explanation.
|
||||
//
|
||||
// "Advanced" here is STRICTER than the outcome=='positive' bucket: a bare
|
||||
// 'applied' (submitted, no reply yet) does NOT count as passing screening.
|
||||
const ADVANCED_STATUSES = new Set(['responded', 'interview', 'offer']);
|
||||
const SUBMITTED_STATUSES = new Set(['applied', 'responded', 'interview', 'offer', 'rejected', 'discarded']);
|
||||
const isAdvanced = (e) => ADVANCED_STATUSES.has(e.normalizedStatus);
|
||||
|
||||
// Only applications we actually submitted count toward channel yield (drop
|
||||
// 'evaluated' = never applied, and 'skip' = self-filtered).
|
||||
const submitted = enriched.filter(e => SUBMITTED_STATUSES.has(e.normalizedStatus));
|
||||
const overallAdvanced = submitted.filter(isAdvanced).length;
|
||||
const overallAdvanceRate = submitted.length > 0
|
||||
? Math.round((overallAdvanced / submitted.length) * 100) : 0;
|
||||
|
||||
const vendorMap = new Map();
|
||||
for (const e of submitted) {
|
||||
const v = e.vendor || 'unknown';
|
||||
if (!vendorMap.has(v)) vendorMap.set(v, { total: 0, advanced: 0 });
|
||||
const entry = vendorMap.get(v);
|
||||
entry.total++;
|
||||
if (isAdvanced(e)) entry.advanced++;
|
||||
}
|
||||
|
||||
// Recommendations only fire on buckets with enough n to not be noise; the
|
||||
// breakdown still SHOWS every bucket (with its n) so nothing is hidden.
|
||||
const vendorBreakdown = [...vendorMap.entries()]
|
||||
.filter(([v]) => v !== 'unknown')
|
||||
.map(([vendor, data]) => ({
|
||||
vendor,
|
||||
total: data.total,
|
||||
advanced: data.advanced,
|
||||
advanceRate: data.total > 0 ? Math.round((data.advanced / data.total) * 100) : 0,
|
||||
sharePct: submitted.length > 0 ? Math.round((data.total / submitted.length) * 100) : 0,
|
||||
sufficientSample: data.total >= MIN_VENDOR_N,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const identifiedCount = submitted.length - (vendorMap.get('unknown')?.total || 0);
|
||||
const vendorAnalysis = {
|
||||
scope: ['greenhouse', 'lever', 'ashby', 'workday'],
|
||||
minSampleForClaim: MIN_VENDOR_N,
|
||||
submitted: submitted.length,
|
||||
identified: identifiedCount,
|
||||
coveragePct: submitted.length > 0 ? Math.round((identifiedCount / submitted.length) * 100) : 0,
|
||||
overallAdvanceRate,
|
||||
breakdown: vendorBreakdown,
|
||||
citation: 'Bommasani et al., Algorithmic Monocultures in Hiring, FAccT 2026 (arXiv:2605.27371)',
|
||||
};
|
||||
|
||||
// --- Score threshold analysis ---
|
||||
const positiveScores = scoresByOutcome.positive.filter(s => s > 0);
|
||||
const minPositiveScore = positiveScores.length > 0 ? Math.min(...positiveScores) : 0;
|
||||
const scoreThreshold = {
|
||||
recommended: minPositiveScore > 0 ? Math.floor(minPositiveScore * 10) / 10 : 3.5,
|
||||
reasoning: positiveScores.length > 0
|
||||
? `Lowest score among positive outcomes is ${minPositiveScore}. No applications below this score led to progress.`
|
||||
: 'Not enough positive outcome data to determine threshold.',
|
||||
positiveRange: positiveScores.length > 0
|
||||
? `${Math.min(...positiveScores)} - ${Math.max(...positiveScores)}`
|
||||
: 'N/A',
|
||||
};
|
||||
|
||||
// --- Tech stack gaps (from negative + self_filtered outcomes) ---
|
||||
// Canonical spellings keyed by lowercased match — the /i regex below returns
|
||||
// the source casing ("react native", "NODEJS"), and without this map each
|
||||
// case variant of the same tech lands in its own techStackGaps bucket.
|
||||
// Keys cover the optional-dot regex variants (node.js/nodejs, vue.js/vuejs).
|
||||
const TECH_CANONICAL = new Map([
|
||||
'JavaScript', 'TypeScript', 'Python', 'Ruby', 'Java', 'Go', 'Rust',
|
||||
'React Native', 'React', 'Angular', 'Django', 'Flask', 'Rails', 'PHP',
|
||||
'Laravel', 'Symfony', 'Kotlin', 'Swift', 'C++', 'C#', '.NET', 'MongoDB',
|
||||
'MySQL', 'PostgreSQL', 'Redis', 'GraphQL', 'REST', 'AWS', 'GCP', 'Azure',
|
||||
'Docker', 'Kubernetes', 'Terraform', 'Supabase', 'Inngest',
|
||||
].map(t => [t.toLowerCase(), t]));
|
||||
TECH_CANONICAL.set('node.js', 'Node.js').set('nodejs', 'Node.js');
|
||||
TECH_CANONICAL.set('vue.js', 'Vue.js').set('vuejs', 'Vue.js');
|
||||
const stackGapCounts = new Map();
|
||||
for (const e of enriched) {
|
||||
if (e.outcome !== 'negative' && e.outcome !== 'self_filtered') continue;
|
||||
if (!e.report?.gaps) continue;
|
||||
for (const gap of e.report.gaps) {
|
||||
// Extract tech keywords from gap descriptions
|
||||
const techs = gap.description.match(/\b(JavaScript|TypeScript|Python|Ruby|Java|Go|Rust|Node\.?js|React Native|React|Angular|Vue\.?js|Django|Flask|Rails|PHP|Laravel|Symfony|Kotlin|Swift|C\+\+|C#|\.NET|MongoDB|MySQL|PostgreSQL|Redis|GraphQL|REST|AWS|GCP|Azure|Docker|Kubernetes|Terraform|Supabase|Inngest)\b/gi);
|
||||
if (techs) {
|
||||
for (const tech of techs) {
|
||||
const normalized = TECH_CANONICAL.get(tech.toLowerCase()) || tech;
|
||||
stackGapCounts.set(normalized, (stackGapCounts.get(normalized) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const techStackGaps = [...stackGapCounts.entries()]
|
||||
.map(([skill, frequency]) => ({ skill, frequency }))
|
||||
.sort((a, b) => b.frequency - a.frequency)
|
||||
.slice(0, 15);
|
||||
|
||||
// --- Generate recommendations ---
|
||||
const recommendations = [];
|
||||
|
||||
// Geo-restriction recommendation
|
||||
const geoBlocker = blockerAnalysis.find(b => b.blocker === 'geo-restriction');
|
||||
if (geoBlocker && geoBlocker.percentage >= 20) {
|
||||
recommendations.push({
|
||||
action: `Tighten location filters in portals.yml -- ${geoBlocker.percentage}% of applications hit a geo-restriction blocker`,
|
||||
reasoning: `${geoBlocker.frequency} of ${enriched.length} offers are location-restricted (US/Canada-only). These are wasted evaluation effort.`,
|
||||
impact: 'high',
|
||||
});
|
||||
}
|
||||
|
||||
// Stack mismatch recommendation
|
||||
const stackBlocker = blockerAnalysis.find(b => b.blocker === 'stack-mismatch');
|
||||
if (stackBlocker && stackBlocker.percentage >= 15) {
|
||||
const topGaps = techStackGaps.slice(0, 3).map(g => g.skill).join(', ');
|
||||
recommendations.push({
|
||||
action: `Filter out roles requiring ${topGaps} as primary stack -- ${stackBlocker.percentage}% hit stack mismatch`,
|
||||
reasoning: `Core stack gaps (${topGaps}) are the most common technical blockers in negative outcomes.`,
|
||||
impact: 'high',
|
||||
});
|
||||
}
|
||||
|
||||
// Score threshold recommendation
|
||||
if (minPositiveScore > 3.0) {
|
||||
recommendations.push({
|
||||
action: `Set minimum score threshold at ${scoreThreshold.recommended}/5 before generating PDFs`,
|
||||
reasoning: `No positive outcomes below ${minPositiveScore}/5. Scores below this are wasted effort.`,
|
||||
impact: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Best archetype recommendation
|
||||
const bestArchetype = archetypeBreakdown.filter(a => a.total >= 2).sort((a, b) => b.conversionRate - a.conversionRate)[0];
|
||||
if (bestArchetype && bestArchetype.conversionRate > 0) {
|
||||
recommendations.push({
|
||||
action: `Double down on "${bestArchetype.archetype}" roles (${bestArchetype.conversionRate}% conversion rate)`,
|
||||
reasoning: `${bestArchetype.positive} of ${bestArchetype.total} applications in this archetype led to positive outcomes.`,
|
||||
impact: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Remote policy recommendation
|
||||
const bestRemote = remotePolicy.filter(r => r.total >= 2).sort((a, b) => b.conversionRate - a.conversionRate)[0];
|
||||
const worstRemote = remotePolicy.filter(r => r.total >= 2 && r.conversionRate === 0)[0];
|
||||
if (worstRemote) {
|
||||
recommendations.push({
|
||||
action: `Avoid "${worstRemote.policy}" roles (0% conversion across ${worstRemote.total} applications)`,
|
||||
reasoning: `None of the ${worstRemote.total} applications with "${worstRemote.policy}" policy led to progress.`,
|
||||
impact: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
// Channel-monoculture recommendation: a concentrated vendor (>= 25% of
|
||||
// submissions, sufficient sample) whose advance rate is well below EVERY OTHER
|
||||
// channel is a dead channel worth routing around, not re-feeding. The baseline
|
||||
// is leave-one-out (this vendor vs all other submissions) — comparing to an
|
||||
// overall rate that INCLUDES the vendor understates the gap when it dominates.
|
||||
let deadChannel = null;
|
||||
for (const v of vendorBreakdown) {
|
||||
if (!v.sufficientSample || v.sharePct < 25) continue;
|
||||
const others = submitted.filter(e => (e.vendor || 'unknown') !== v.vendor);
|
||||
if (others.length === 0) continue;
|
||||
const othersRate = Math.round((others.filter(isAdvanced).length / others.length) * 100);
|
||||
// Meaningful gap only: the rest of the pipeline must be doing at least
|
||||
// moderately better, so we're not flagging a uniformly cold market.
|
||||
if (v.advanceRate < othersRate && othersRate - v.advanceRate >= 10) {
|
||||
if (!deadChannel || v.advanceRate < deadChannel.advanceRate) deadChannel = { ...v, othersRate };
|
||||
}
|
||||
}
|
||||
if (deadChannel) {
|
||||
recommendations.push({
|
||||
action: `Route ${deadChannel.vendor} companies through referral / direct contact -- ${deadChannel.sharePct}% of your applications flow through it at a ${deadChannel.advanceRate}% advance rate (vs ${deadChannel.othersRate}% through other channels)`,
|
||||
reasoning: `${deadChannel.advanced}/${deadChannel.total} ${deadChannel.vendor} applications advanced past screening, well below your other channels. Under algorithmic monoculture (Bommasani et al., FAccT 2026) a shared screener's rejections are correlated -- re-applying the same profile through the same engine has diminishing returns; a human channel bypasses it. Channel yield, not a discrimination claim.`,
|
||||
impact: 'high',
|
||||
});
|
||||
}
|
||||
|
||||
// Date range
|
||||
const dates = enriched.map(e => e.date).filter(Boolean).sort();
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
total: enriched.length,
|
||||
dateRange: { from: dates[0], to: dates[dates.length - 1] },
|
||||
analysisDate: new Date().toISOString().split('T')[0],
|
||||
byOutcome: {
|
||||
positive: enriched.filter(e => e.outcome === 'positive').length,
|
||||
negative: enriched.filter(e => e.outcome === 'negative').length,
|
||||
self_filtered: enriched.filter(e => e.outcome === 'self_filtered').length,
|
||||
pending: enriched.filter(e => e.outcome === 'pending').length,
|
||||
},
|
||||
},
|
||||
funnel,
|
||||
scoreComparison,
|
||||
archetypeBreakdown,
|
||||
blockerAnalysis,
|
||||
remotePolicy,
|
||||
companySizeBreakdown,
|
||||
vendorAnalysis,
|
||||
scoreThreshold,
|
||||
techStackGaps,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Summary mode (human-readable) ---
|
||||
function printSummary(result) {
|
||||
if (result.error) {
|
||||
console.log(`\n${result.error}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { metadata, funnel, scoreComparison, archetypeBreakdown, blockerAnalysis, remotePolicy, scoreThreshold, techStackGaps, recommendations } = result;
|
||||
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(` Pattern Analysis — ${metadata.analysisDate}`);
|
||||
console.log(` ${metadata.total} applications (${metadata.dateRange.from} to ${metadata.dateRange.to})`);
|
||||
console.log(`${'='.repeat(60)}\n`);
|
||||
|
||||
// Funnel
|
||||
console.log('CONVERSION FUNNEL');
|
||||
console.log('-'.repeat(40));
|
||||
const funnelOrder = ['evaluated', 'applied', 'responded', 'interview', 'offer', 'rejected', 'discarded', 'skip'];
|
||||
for (const status of funnelOrder) {
|
||||
if (funnel[status]) {
|
||||
const pct = Math.round((funnel[status] / metadata.total) * 100);
|
||||
console.log(` ${status.padEnd(15)} ${String(funnel[status]).padStart(3)} (${pct}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Score comparison
|
||||
console.log('\nSCORE BY OUTCOME');
|
||||
console.log('-'.repeat(40));
|
||||
for (const [group, stats] of Object.entries(scoreComparison)) {
|
||||
if (stats.count > 0) {
|
||||
console.log(` ${group.padEnd(15)} avg ${stats.avg}/5 (${stats.count} entries, range ${stats.min}-${stats.max})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Blockers
|
||||
if (blockerAnalysis.length > 0) {
|
||||
console.log('\nTOP BLOCKERS');
|
||||
console.log('-'.repeat(40));
|
||||
for (const b of blockerAnalysis) {
|
||||
console.log(` ${b.blocker.padEnd(20)} ${String(b.frequency).padStart(2)}x (${b.percentage}% of all)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remote policy
|
||||
console.log('\nREMOTE POLICY');
|
||||
console.log('-'.repeat(40));
|
||||
for (const r of remotePolicy) {
|
||||
console.log(` ${r.policy.padEnd(20)} ${String(r.total).padStart(2)} total, ${r.positive} positive (${r.conversionRate}%)`);
|
||||
}
|
||||
|
||||
// Tech gaps
|
||||
if (techStackGaps.length > 0) {
|
||||
console.log('\nTOP TECH STACK GAPS (negative outcomes)');
|
||||
console.log('-'.repeat(40));
|
||||
for (const g of techStackGaps.slice(0, 10)) {
|
||||
console.log(` ${g.skill.padEnd(20)} ${g.frequency}x`);
|
||||
}
|
||||
}
|
||||
|
||||
// ATS vendor / channel analysis
|
||||
const va = result.vendorAnalysis;
|
||||
if (va && va.breakdown.length > 0) {
|
||||
console.log('\nATS CHANNEL ANALYSIS (community ATS only)');
|
||||
console.log('-'.repeat(40));
|
||||
console.log(` vendor identified for ${va.identified}/${va.submitted} submissions (${va.coveragePct}% coverage); overall advance rate ${va.overallAdvanceRate}%`);
|
||||
for (const v of va.breakdown) {
|
||||
const flag = v.sufficientSample ? '' : ' (n too small for a claim)';
|
||||
console.log(` ${v.vendor.padEnd(12)} ${String(v.total).padStart(3)} apps ${String(v.sharePct).padStart(3)}% share ${String(v.advanceRate).padStart(3)}% advance${flag}`);
|
||||
}
|
||||
console.log(' Channel yield, not discrimination — see Bommasani et al., FAccT 2026.');
|
||||
}
|
||||
|
||||
// Score threshold
|
||||
console.log(`\nSCORE THRESHOLD: ${scoreThreshold.recommended}/5`);
|
||||
console.log(` ${scoreThreshold.reasoning}`);
|
||||
|
||||
// Recommendations
|
||||
if (recommendations.length > 0) {
|
||||
console.log(`\nRECOMMENDATIONS`);
|
||||
console.log('='.repeat(60));
|
||||
for (let i = 0; i < recommendations.length; i++) {
|
||||
const r = recommendations[i];
|
||||
console.log(` ${i + 1}. [${r.impact.toUpperCase()}] ${r.action}`);
|
||||
console.log(` ${r.reasoning}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// --- Run ---
|
||||
if (args.includes('--self-test')) {
|
||||
runSelfTest();
|
||||
}
|
||||
|
||||
const result = analyze();
|
||||
|
||||
if (summaryMode) {
|
||||
printSummary(result);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
if (result.error) process.exit(1);
|
||||
Reference in new issue
Block a user