panelist-roles.mjs
Rama main · réplica de lectura del repositorio original.
#!/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(`(?<![\\p{L}\\p{N}])${escapeRe(word).replace(/ /g, '\\s+')}${whole ? '(?![\\p{L}\\p{N}])' : ''}`, 'u');
return { raw, re };
}
/** Validate a parsed table; returns { fallback, roles } or throws Error with a plain message. */
export function validateTable(doc) {
if (!doc || typeof doc !== 'object') throw new Error('table is empty or not a YAML mapping');
const str = (v) => 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 <invite.txt|invite.ics> # 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 <invite> --summary # one line per panelist
node panelist-roles.mjs <invite> --table <panelist-roles.yml> # 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();
}