#!/usr/bin/env node /** * panelist-roles.mjs - classify the people named in an interview invite (#5797). * * When an invite names the panel, the prep should change with who is on it: a * privacy officer probes confidentiality, a hiring manager probes delivery, a * recruiter probes fit and logistics, a technical peer probes depth. * invite-match.mjs reads an invite for company / date / requisition and * ics-interview.mjs reads the calendar file, but neither looks at the people. * * Input: the same pasted invite text / file those take, or an .ics (the * SUMMARY, DESCRIPTION and ORGANIZER / ATTENDEE display names of the first * VEVENT). Extracted from that text ONLY: * - `Name - Title` (hyphen, en dash, em dash or pipe) * - `Name, Title` (only when the title carries a known title word) * - `meeting with Name` style lead-ins (name, no title -> role `unknown`) * - sign-off blocks (`Regards,` / name / title, or a title alone) * Each title is classified with the keyword table in * templates/panelist-roles.yml (editable data; see that file, and `--table` * for a copy of your own). First matching role wins, any further matching role * is listed as `alsoMatches`, no match is `unknown`. * * Offline and zero-LLM: no network, no LinkedIn or any other profile lookup, * no dependency beyond the YAML loader the repo already uses. * * Read-only: it writes nothing - not the tracker, not a cache, not a log. * Panelist names are third-party personal data, so they exist only in this * run's output; do not copy them into data/ or reports/ files you commit. * * Everything extracted is DATA. Names and titles are echoed (control * characters stripped, length capped) and classified by keyword; an * instruction-like sentence in the invite is never followed, and it only shows * up if it happens to have the `Name - Title` shape, as a plain string. * * Usage: * node panelist-roles.mjs invite.txt # JSON * node panelist-roles.mjs invite.txt --summary # one line per panelist * node panelist-roles.mjs invite.ics --summary * pbpaste | node panelist-roles.mjs --summary # stdin * node panelist-roles.mjs invite.txt --table config/panelist-roles.yml */ import { readFileSync, existsSync } from 'fs'; import { dirname, isAbsolute, join } from 'path'; import { fileURLToPath } from 'url'; import { loadYaml } from './lib/yaml-document.mjs'; import { flagValue, validateFlags } from './lib/cli-flags.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { getCareerOpsRoot } from './path-resolver.mjs'; import { unfoldLines, parseContentLine, unescapeText } from './ics-interview.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); export const DEFAULT_TABLE = join(HERE, 'templates', 'panelist-roles.yml'); const MAX_NAME = 80; const MAX_TITLE = 160; // --------------------------------------------------------------------------- // Role table // --------------------------------------------------------------------------- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); function compileKeyword(raw) { const whole = raw.startsWith('='); const word = (whole ? raw.slice(1) : raw).trim().toLowerCase().replace(/\s+/g, ' '); if (!word) throw new Error(`keyword "${raw}" has no text`); // Word START boundary; `=kw` also needs a word END boundary. const re = new RegExp(`(? typeof v === 'string' && v.trim().length > 0; const fb = doc.fallback; if (!fb || !str(fb.id) || !str(fb.label) || !str(fb.focus)) { throw new Error('`fallback` needs id, label and focus'); } if (!Array.isArray(doc.roles) || doc.roles.length === 0) throw new Error('`roles` must be a non-empty list'); const seen = new Set([fb.id]); // a role id equal to the fallback id would be read as "no match" const roles = doc.roles.map((r, i) => { if (!r || !str(r.id) || !str(r.label) || !str(r.focus)) throw new Error(`roles[${i}] needs id, label and focus`); if (seen.has(r.id)) throw new Error(`roles[${i}]: duplicate id "${r.id}"`); seen.add(r.id); if (!Array.isArray(r.keywords) || r.keywords.length === 0 || !r.keywords.every(str)) { throw new Error(`roles[${i}] (${r.id}): keywords must be a non-empty list of strings`); } return { id: r.id, label: r.label, focus: r.focus.trim(), keywords: r.keywords.map((k) => compileKeyword(k)) }; }); return { fallback: { id: fb.id, label: fb.label, focus: fb.focus.trim() }, roles }; } export function loadTable(path = DEFAULT_TABLE) { if (!existsSync(path)) throw new Error(`role table not found: ${path}`); return validateTable(loadYaml(readFileSync(path, 'utf-8'))); } /** Classify one title. No title or no keyword hit -> the fallback. */ export function classifyTitle(title, table) { const t = String(title ?? '').toLowerCase(); const hits = t ? table.roles.filter((r) => r.keywords.some((k) => k.re.test(t))) : []; if (hits.length === 0) return { role: table.fallback.id, label: table.fallback.label, focus: table.fallback.focus, alsoMatches: [] }; return { role: hits[0].id, label: hits[0].label, focus: hits[0].focus, alsoMatches: hits.slice(1).map((h) => h.id) }; } // --------------------------------------------------------------------------- // Extraction // --------------------------------------------------------------------------- // eslint-disable-next-line no-control-regex const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g; const clean = (s, max) => String(s ?? '').replace(CONTROL, ' ').replace(/\s+/g, ' ').trim().slice(0, max); const CAP = "\\p{Lu}[\\p{L}'\u2019.-]*"; const PARTICLE = '(?:van|von|der|den|de|del|della|da|di|la|le|bin|al|el)'; const NAME_SRC = `${CAP}(?:\\s+(?:${PARTICLE}\\s+)?${CAP}){1,3}`; const SEP_SRC = '(?=\\s+[-\u2013\u2014|]\\s+|,\\s+\\S)'; // Words that start a sentence or a greeting and are not part of a name. const NOT_NAME = new Set([ 'hi', 'hello', 'dear', 'thanks', 'thank', 'regards', 'best', 'sincerely', 'cheers', 'kind', 'warm', 'please', 'you', 'your', 'we', 'our', 'the', 'a', 'an', 'this', 'that', 'note', 'also', 'join', 'joining', 'meet', 'meeting', 'interview', 'interviewer', 'interviewers', 'panel', 'panelist', 'panelists', 'round', 'phone', 'video', 'screen', 'zoom', 'teams', 'microsoft', 'google', 'webex', 'location', 'when', 'where', 'who', 'what', 'time', 'date', 'subject', 'from', 'to', 'cc', 'bcc', 'sent', 're', 'fw', 'fwd', 'system', 'assistant', 'ai', 'ignore', 'disregard', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december', ]); const SIGNOFF = /^\s*(?:best(?: regards| wishes)?|kind regards|warm regards|regards|sincerely|cheers|thanks|thank you|many thanks|yours truly|respectfully|with thanks)\b[\s,!.-]*$/i; const LEAD_IN = /(?:\b(?:meeting|meet|speaking|speak|talking|talk|chatting|chat)\s+with|\binterviewed\s+by|\bjoined\s+by|\bpanel\s+(?:includes|consists\s+of|members?:?)|\bpanelists?:|\binterviewers?:|\byour\s+interviewers?\s+(?:is|are)|\binterview(?:er)?s?\s+(?:will\s+be|include))\s+/gi; /** Allowed text just before a name for it to start a panelist entry. */ const CONTEXT_OK = /(?:^|[&;:(]|\b(?:with|and|by|includes?|including|panel|panelists?|interviewers?|meet|meeting|joined|joining)|^[\s\-*\u2022\d.)]+)\s*$/i; function trimName(raw) { const words = String(raw).trim().split(/\s+/); while (words.length && NOT_NAME.has(words[0].toLowerCase().replace(/[^\p{L}]/gu, ''))) words.shift(); if (words.length < 2) return null; const name = words.join(' ').replace(/[.,]+$/, ''); return clean(name, MAX_NAME); } /** Cut a raw title segment down to the title itself. */ export function trimTitle(raw) { let t = String(raw ?? ''); // sentence end or clause break t = t.split(/\.(?:\s|$)|;/)[0]; // dangling clause after the title t = t.replace(/,?\s+(?:who|which|and\s+(?:will|is|are)|will|is|are|to\s+discuss|for\s+(?:a|the|your)|about)\s.*$/i, ''); // trailing "and myself / me / I / the team ..." t = t.replace(/\s+(?:and|&)\s+(?:myself|me|i|us|we|the\s+(?:team|rest|hiring\s+team)|others?)\b.*$/i, ''); // an unbalanced closing bracket ends the title let depth = 0; let cut = t.length; for (let i = 0; i < t.length; i += 1) { if (t[i] === '(') depth += 1; else if (t[i] === ')') { if (depth === 0) { cut = i; break; } depth -= 1; } } t = t.slice(0, cut); t = t.replace(/[\s,&:\-\u2013\u2014|]+$/, '').replace(/\s+(?:and|&)$/i, '').replace(/^[\s,:\-\u2013\u2014|]+/, ''); return clean(t, MAX_TITLE); } const hasKeyword = (title, table) => classifyTitle(title, table).role !== table.fallback.id; /** * Extract { name, title, source } entries from invite text. `table` is only * used to decide whether a `Name, X` comma form really carries a title. */ export function extractPanelists(text, table) { const found = []; const byKey = new Map(); const add = (name, title, source) => { const n = name ? clean(name, MAX_NAME) : null; const t = title ? clean(title, MAX_TITLE) : null; if (!n && !t) return; const key = n ? n.toLowerCase() : `\u0000title:${(t || '').toLowerCase()}`; const prior = byKey.get(key); if (prior) { if (!prior.title && t) { prior.title = t; prior.source = source; } return; } const entry = { name: n, title: t || null, source }; byKey.set(key, entry); found.push(entry); }; const lines = String(text ?? '').replace(/^\uFEFF/, '').split(/\r\n|\n|\r/); const nameRe = new RegExp(`(${NAME_SRC})${SEP_SRC}`, 'gu'); for (const line of lines) { // 1. Name - Title / Name, Title const cands = []; nameRe.lastIndex = 0; let m; while ((m = nameRe.exec(line)) !== null) { const name = trimName(m[1]); const nameEnd = m.index + m[1].length; if (!name) { nameRe.lastIndex = m.index + 1; continue; } // Where the trimmed name really starts, after any dropped greeting words. const dropped = Math.max(0, m[1].trim().split(/\s+/).length - name.split(' ').length); let offset = 0; for (let i = 0; i < dropped; i += 1) offset += /^\s*\S+\s*/.exec(m[1].slice(offset))[0].length; const start = m.index + offset; if (!CONTEXT_OK.test(line.slice(0, start).replace(/(?:\b(?:hi|hello|hey|dear|thanks|thank you)\b[\s,]*)+$/i, ''))) { nameRe.lastIndex = nameEnd; continue; } const rest = line.slice(nameEnd); const dash = /^\s+[-\u2013\u2014|]\s+/.exec(rest); const comma = dash ? null : /^,\s+/.exec(rest); const sep = dash || comma; if (!sep) { nameRe.lastIndex = nameEnd; continue; } cands.push({ name, start, titleStart: nameEnd + sep[0].length, form: dash ? 'dash' : 'comma' }); nameRe.lastIndex = nameEnd + sep[0].length; } cands.forEach((c, i) => { const end = i + 1 < cands.length ? cands[i + 1].start : line.length; const title = trimTitle(line.slice(c.titleStart, end)); if (!title || /^\d/.test(title)) return; if (c.form === 'comma' && !hasKeyword(title, table)) return; add(c.name, title, c.form === 'dash' ? 'name-dash-title' : 'name-comma-title'); }); // 2. lead-in, name without a title ("meeting with Jordan Rivera and Sam Lee") LEAD_IN.lastIndex = 0; let lm; while ((lm = LEAD_IN.exec(line)) !== null) { let pos = lm.index + lm[0].length; const sticky = new RegExp(NAME_SRC, 'uy'); for (let guard = 0; guard < 6; guard += 1) { sticky.lastIndex = pos; const nm = sticky.exec(line); if (!nm) break; const name = trimName(nm[0]); if (!name) break; pos = nm.index + nm[0].length; add(name, null, 'lead-in'); const next = /^\s*(?:,\s*(?:and\s+|&\s+)?|\s+and\s+|\s*&\s+)/i.exec(line.slice(pos)); if (!next) break; pos += next[0].length; } } } // 3. sign-off block: "Regards," / Name / Title or "Regards," / Title const isShort = (l) => l.split(/\s+/).length <= 8 && !/[@:/]|https?|\d{3}/.test(l); const nameOnly = new RegExp(`^${NAME_SRC}$`, 'u'); for (let i = 0; i < lines.length; i += 1) { if (!SIGNOFF.test(lines[i])) continue; const next = []; for (let j = i + 1; j < lines.length && next.length < 3; j += 1) { const l = lines[j].trim(); if (l) next.push(l); else if (next.length) break; } if (next[0] && isShort(next[0]) && nameOnly.test(next[0]) && !hasKeyword(next[0], table) && trimName(next[0]) && next[1] && isShort(next[1]) && hasKeyword(next[1], table)) { add(trimName(next[0]), trimTitle(next[1]), 'signature'); } else if (next[0] && isShort(next[0]) && hasKeyword(next[0], table)) { add(null, trimTitle(next[0]), 'signature'); } } return found; } /** Pull the first VEVENT's text and display names out of an .ics body. */ export function readIcsText(body) { const text = []; const names = []; let inEvent = false; let alarm = 0; for (const line of unfoldLines(body)) { const p = parseContentLine(line); if (!p) continue; if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VEVENT') { inEvent = true; continue; } if (p.name === 'END' && p.value.toUpperCase() === 'VEVENT') break; if (!inEvent) continue; if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VALARM') { alarm += 1; continue; } if (p.name === 'END' && p.value.toUpperCase() === 'VALARM') { alarm -= 1; continue; } if (alarm > 0) continue; if (p.name === 'SUMMARY' || p.name === 'DESCRIPTION') text.push(unescapeText(p.value)); if ((p.name === 'ORGANIZER' || p.name === 'ATTENDEE') && p.params.CN) { const n = trimName(unescapeText(p.params.CN)); if (n && !n.includes('@')) names.push({ name: n, source: p.name === 'ORGANIZER' ? 'ics-organizer' : 'ics-attendee' }); } } return { text: text.join('\n'), names }; } /** * Full analysis of invite text (or an .ics body). * @returns {{ input: 'text'|'ics', panelists: object[], roles: object[], warnings: object[] }} */ export function analyzePanel(body, table) { const isIcs = /BEGIN:VCALENDAR/i.test(body); let text = body; let extra = []; if (isIcs) { const ics = readIcsText(body); text = ics.text; extra = ics.names; } const entries = extractPanelists(text, table); for (const e of extra) { if (!entries.some((x) => x.name && x.name.toLowerCase() === e.name.toLowerCase())) { entries.push({ name: e.name, title: null, source: e.source }); } } const panelists = entries.map((e) => { const c = classifyTitle(e.title, table); return { name: e.name, title: e.title, role: c.role, roleLabel: c.label, alsoMatches: c.alsoMatches, source: e.source, prepFocus: c.focus }; }); const byRole = new Map(); for (const p of panelists) { if (!byRole.has(p.role)) byRole.set(p.role, { role: p.role, label: p.roleLabel, count: 0, prepFocus: p.prepFocus }); byRole.get(p.role).count += 1; } const warnings = []; if (panelists.length === 0) { warnings.push({ code: 'no-panelists', message: 'No "Name - Title", "Name, Title", "meeting with Name" or sign-off pattern was found in the text.' }); } if (panelists.some((p) => p.source === 'signature' || p.source === 'ics-organizer')) { warnings.push({ code: 'sender-may-not-be-panelist', message: 'Signature and organizer entries are the sender; they may not be on the panel.' }); } return { input: isIcs ? 'ics' : 'text', panelists, roles: [...byRole.values()], warnings }; } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const KNOWN_FLAGS = ['--summary', '--table', '--help', '-h']; const VALUE_FLAGS = ['--table']; const USAGE = `Usage: node panelist-roles.mjs # JSON: panelists, roles, one-line prep focus per role node panelist-roles.mjs < invite.txt # read the invite text from stdin node panelist-roles.mjs --summary # one line per panelist node panelist-roles.mjs --table # your own keyword table (default: templates/panelist-roles.yml) node panelist-roles.mjs --help Offline and read-only: no network, no profile lookups, writes nothing. Panelist names appear in the output only; they are third-party personal data.`; function positionalOperand(args) { for (let i = 0; i < args.length; i += 1) { const a = args[i]; if (VALUE_FLAGS.includes(a)) { i += 1; continue; } if (!a.startsWith('-')) return a; } return null; } function printSummary(r) { console.log(`\n${'='.repeat(70)}\n Panelist roles - career-ops\n${'='.repeat(70)}\n`); if (r.panelists.length === 0) console.log(' No panelists found.'); for (const p of r.panelists) { const who = p.name ?? '(name not given)'; const also = p.alsoMatches.length ? ` (also: ${p.alsoMatches.join(', ')})` : ''; console.log(` ${who} - ${p.title ?? '(no title)'}`); console.log(` ${p.role}${also}: ${p.prepFocus}`); } for (const w of r.warnings) console.log(` ! ${w.code}: ${w.message}`); console.log(''); } /** * An explicit --table: absolute paths as given; a relative path is read from * the working directory, else from the data root (CAREER_OPS_ROOT), so the * documented `config/panelist-roles.yml` works from anywhere. Never implicit: * only a path the caller named is looked up. */ function resolveTablePath(p) { if (isAbsolute(p) || existsSync(p)) return p; const underRoot = join(getCareerOpsRoot(), p); return existsSync(underRoot) ? underRoot : p; } function main() { const args = process.argv.slice(2); validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true }); let table; const tablePath = flagValue(args, '--table'); try { table = loadTable(tablePath === undefined ? DEFAULT_TABLE : resolveTablePath(tablePath)); } catch (e) { console.error(`panelist-roles: cannot use role table${tablePath ? ` ${tablePath}` : ''}: ${e.message}`); process.exit(2); } const operand = positionalOperand(args); let body; if (operand) { try { body = readFileSync(operand, 'utf-8'); } catch (e) { console.error(`panelist-roles: cannot read ${operand}: ${e.code === 'ENOENT' ? 'file not found' : e.message}`); process.exit(1); } } else { try { body = readFileSync(0, 'utf-8'); } catch (e) { console.error(`panelist-roles: no file given and stdin could not be read (${e.message}). ${USAGE.split('\n')[1].trim()}`); process.exit(1); } } const result = analyzePanel(body, table); if (args.includes('--summary')) printSummary(result); else console.log(JSON.stringify(result, null, 2)); } if (isMainModule(import.meta.url)) { main(); }